From 551849aaa000c4889eefeb8c296daa9cd13b44f5 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Thu, 2 Jul 2026 10:31:27 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(deploy):=20AI=20Studio=20production?= =?UTF-8?q?=20deployment=20=E2=80=94=20compose=20+=20Swarm=20paths=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(execution-worker): connect to TEMPORAL_ADDRESS instead of implicit localhost Worker.create without an explicit connection dials 127.0.0.1:7233, ignoring the TEMPORAL_ADDRESS env var — correct in local dev, broken in any deployment where Temporal is not on loopback. * fix(execution-worker): treat empty-conditions decision branch as catch-all The no_branch_matched error message and the Sales Inquiry reference template both treat a branch with no conditions as the explicit catch-all, but branchMatches returned false for it — any input classified outside the keyword branches failed the whole run. Supersedes the empty-conditions bullet of packages/execution-core/decision-no-match.decision-log.md (the strict fail-fast core of that decision is unchanged); see apps/execution-worker/decision-catch-all.decision-log.md. * feat(backend): add per-ip rate limit on the execute route Fixed-window, in-memory limiter (WB-229 abuse gate). Disabled unless RATE_LIMIT_EXECUTE_PER_MINUTE / RATE_LIMIT_EXECUTE_PER_DAY are set, so local dev is unaffected. TRUST_PROXY=true reads the client IP from X-Forwarded-For — only enable behind a proxy that sets it. * feat(deploy): containerize ai studio stack for production deployment deploy/ai-studio/: multi-target Dockerfile (runtime/migrate/web), production docker-compose (only nginx public, pinned images, automatic migrations), nginx SPA+API proxy with SSE tuning and per-request DNS re-resolution, .env.example with Mistral Small 3.2 default, DevOps README, and a decision log covering the architecture choices. tsx becomes a real dependency of backend and worker (start:prod runs without an .env file); .dockerignore now keeps **/.env out of build contexts. Verified end-to-end: Sales Inquiry Pipeline to execution_completed with live SSE through nginx; rate limiter returns 429 past the budget. * feat(deploy): add swarm overlay aligned with workflow-builder infra tools/deployment/ mirrors the workflow-builder repo's deployment path (build-docker.sh, deploy.sh, ansible deploy-application playbook) and consumes the same three images from deploy/ai-studio/Dockerfile — only the orchestration layer differs. Deviations forced by AI Studio being stateful: node-pinned volumes for Postgres/Temporal, post-deploy migration step (Swarm ignores depends_on), attachable internal network with short DNS aliases, and an AUTH_ENABLED-gated gatekeeper so the public demo stays login-free. Stack template render-verified in both auth modes; status 'Proposed' pending the DevOps conversation. * feat(backend): apply drizzle migrations on boot drizzle-orm's programmatic migrator runs the SQL files from apps/backend/drizzle/ before the server accepts traffic. A failure (database still starting) exits the process; container restart policies retry until it converges. drizzle-kit stays a devDependency — db:migrate remains available for out-of-band use. * refactor(deploy): drop the migrate service and image The backend migrates itself at boot, so the migrate Dockerfile target, compose service, and the Swarm playbook's post-deploy migration task (plus its attachable-network requirement) all go away. Two images remain: runtime and web. The worker now waits for the backend healthcheck so it never touches a pre-migration schema. Verified on a wiped stack: virgin database boots, backend logs 'database migrations applied' before listening, Sales Inquiry Pipeline runs to execution_completed over live SSE, rate limiter returns 429 past the budget. * style: trim comments to the non-obvious Keep only what the code cannot say itself: traps (Worker.create's implicit localhost, corepack/pnpm 10 failure, offline mode leaking into lifecycle scripts), constraints (single-replica migrator, X-Forwarded-For trust), and magic values. Drop the narration. * revert(execution-worker): move the decision catch-all fix to its own pr Reverts the empty-conditions-as-catch-all change (ccf7375) and its decision log. It changes execution semantics and supersedes a clause of decision-no-match.decision-log.md — that deserves a focused review, not a ride-along in a deployment PR. * docs(deploy): drop the deployment decision log AI Studio is a POC — the README and the comments on the non-obvious pieces carry what operators need; full architecture rationale is premature at this stage. * fix(deploy): add healthcheck, restart policies, and overlay driver to swarm stack Backend healthcheck (fetch /api/health) lets Swarm detect when migrations are done — without it the worker can hit a pre-migration schema. Explicit restart_policy on every service replaces the implicit Swarm default; crash-looping services (worker, temporal) get max_attempts. Internal network gets driver: overlay for clarity. * Add actions code * Remove old deployment code --------- Co-authored-by: Jan Librowski Co-authored-by: Jakub Kubacki --- .dockerignore | 15 +- .github/workflows/deploy-ai-studio.yml | 102 +++++++++++++ CLAUDE.md | 7 +- apps/backend/package.json | 2 + apps/backend/src/db/migrate.ts | 18 +++ apps/backend/src/env.ts | 4 + .../backend/src/middleware/rate-limit.test.ts | 128 ++++++++++++++++ apps/backend/src/middleware/rate-limit.ts | 110 ++++++++++++++ apps/backend/src/server.ts | 22 +++ apps/execution-worker/package.json | 4 +- .../src/engines/temporal/worker.ts | 6 +- deploy/ai-studio/.env.example | 36 +++++ deploy/ai-studio/Dockerfile | 46 ++++++ deploy/ai-studio/README.md | 111 ++++++++++++++ deploy/ai-studio/docker-compose.yml | 141 ++++++++++++++++++ deploy/ai-studio/nginx/default.conf | 58 +++++++ pnpm-lock.yaml | 6 + 17 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/deploy-ai-studio.yml create mode 100644 apps/backend/src/db/migrate.ts create mode 100644 apps/backend/src/middleware/rate-limit.test.ts create mode 100644 apps/backend/src/middleware/rate-limit.ts create mode 100644 deploy/ai-studio/.env.example create mode 100644 deploy/ai-studio/Dockerfile create mode 100644 deploy/ai-studio/README.md create mode 100644 deploy/ai-studio/docker-compose.yml create mode 100644 deploy/ai-studio/nginx/default.conf diff --git a/.dockerignore b/.dockerignore index 84a60d2c3..e0fcd955c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,18 +2,29 @@ .git .vscode .idea +.claude # external dependencies node_modules +**/node_modules # docker files docker-compose*.yml **/Dockerfile* +# build artifacts +dist/ +**/dist +coverage/ +**/coverage + # not needed files README.md tools/ !tools/deployment/nginx .gitignore -.env -coverage/ + +# env files hold secrets — never in a build context +**/.env +**/.env.* +!**/.env.example diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml new file mode 100644 index 000000000..8b87e52d3 --- /dev/null +++ b/.github/workflows/deploy-ai-studio.yml @@ -0,0 +1,102 @@ +name: Deploy AI Studio + +on: + push: + branches: ["WB-229-swarm-alignment"] + workflow_dispatch: + inputs: + image_tag: + description: 'Image tag (defaults to short SHA)' + required: false + +permissions: + id-token: write + contents: read + +env: + REGISTRY: synergycodes.azurecr.io + APP: wb-ai-studio + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.tag.outputs.value }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve image tag + id: tag + run: | + TAG="${{ inputs.image_tag || github.sha }}" + echo "value=${TAG::7}" >> "$GITHUB_OUTPUT" + + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Log in to ACR + run: az acr login --name synergycodes + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push runtime image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/ai-studio/Dockerfile + target: runtime + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.APP }}:${{ steps.tag.outputs.value }}-runtime + ${{ env.REGISTRY }}/${{ env.APP }}:latest-runtime + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP }}:latest-runtime + cache-to: type=inline + + - name: Build and push web image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/ai-studio/Dockerfile + target: web + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.APP }}:${{ steps.tag.outputs.value }}-web + ${{ env.REGISTRY }}/${{ env.APP }}:latest-web + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP }}:latest-web + cache-to: type=inline + build-args: | + VITE_BACKEND_URL= + + deploy: + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Install Azure SSH extension + run: az extension add --name ssh --yes + + - name: Refresh docker compose on Azure VM + run: | + az ssh vm \ + --name ${{ vars.AI_STUDIO_VM_NAME }} \ + --resource-group ${{ vars.AI_STUDIO_VM_RG }} \ + --command " + set -e + az acr login --name synergycodes + docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml pull + docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml up -d --no-build --force-recreate + " diff --git a/CLAUDE.md b/CLAUDE.md index 6cf746e34..8e389a206 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Three onboarding paths (A installs from npm; B, C run the repo locally). README | `pnpm preflight` | B/C | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents | | `pnpm dev` / `pnpm dev:demo` | B | Demo (UI only, port 4200). No backend, no Docker | | `pnpm infra:up` | C | Start Postgres + Temporal in Docker. Required before backend/worker | -| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations. First run, or after schema changes | +| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations out-of-band (backend also auto-migrates on boot) | | `pnpm dev:ai-studio` | C | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) | | `pnpm dev:backend` | C | Backend only (debug). Needs infra up | | `pnpm dev:worker` | C | Execution worker only (debug). Needs infra up | @@ -22,7 +22,7 @@ Three onboarding paths (A installs from npm; B, C run the repo locally). README | `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` | | `pnpm check` | - | Lint + typecheck + format + knip | -Path B is UI-only and does not need Docker. Path C requires `pnpm infra:up` before backend/worker can start, and `db:migrate` on the first run. +Path B is UI-only and does not need Docker. Path C requires `pnpm infra:up` before backend/worker can start; the backend applies pending migrations automatically at boot. ### Agent signals @@ -42,6 +42,9 @@ Long-running processes already emit stable log lines that scripts and agents can ``` tools/ - Root dev scripts: preflight, setup:env, infra wait + deployment/ - Swarm/Ansible deploy path mirroring the workflow-builder repo (ACR, Traefik) +deploy/ + ai-studio/ - Production deployment: Dockerfile (runtime/web), compose, nginx, README apps/ demo/ - Reference app consuming the SDK (React + Vite, port 4200) ai-studio/ - Reference AI workflow product (React + Vite, port 4201) diff --git a/apps/backend/package.json b/apps/backend/package.json index fec96ac60..3f1e1fb1a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "tsx watch --env-file=.env ./src/server.ts", "start": "tsx --env-file=.env ./src/server.ts", + "start:prod": "tsx ./src/server.ts", "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix", @@ -24,6 +25,7 @@ "drizzle-orm": "^0.44.0", "hono": "^4.7.0", "postgres": "^3.4.5", + "tsx": "^4.19.3", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..6d7d4c68d --- /dev/null +++ b/apps/backend/src/db/migrate.ts @@ -0,0 +1,18 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { fileURLToPath } from 'node:url'; +import postgres from 'postgres'; + +import { env } from '../env'; + +// Same SQL files as `pnpm db:migrate`. Concurrent backends would race the +// migrator — single replica assumed. +export async function runMigrations(): Promise { + const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url)); + const sql = postgres(env.DATABASE_URL, { max: 1 }); + try { + await migrate(drizzle(sql), { migrationsFolder }); + } finally { + await sql.end(); + } +} diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 12645d6d6..a813c14f6 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -12,4 +12,8 @@ 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'), + // 0 disables (dev default); the deploy compose sets both + RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), + RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), + TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', }; diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts new file mode 100644 index 000000000..7b9196358 --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -0,0 +1,128 @@ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; + +import { type RateLimitOptions, createRateLimitMiddleware } from './rate-limit'; + +const MINUTE_MS = 60_000; +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeApp(overrides: Partial = {}) { + let timestamp = 0; + const app = new Hono(); + app.use( + '/api/workflows/:id/execute', + createRateLimitMiddleware({ + perMinute: 2, + perDay: 5, + trustProxy: true, + now: () => timestamp, + ...overrides, + }), + ); + app.post('/api/workflows/:id/execute', (c) => c.json({ ok: true }, 202)); + + return { + app, + advance(ms: number) { + timestamp += ms; + }, + execute(ip = '203.0.113.7') { + return app.request('/api/workflows/wf-1/execute', { + method: 'POST', + headers: { 'x-forwarded-for': ip }, + }); + }, + }; +} + +describe('createRateLimitMiddleware', () => { + it('allows requests under the limit', async () => { + const { execute } = makeApp(); + + const first = await execute(); + const second = await execute(); + expect(first.status).toBe(202); + expect(second.status).toBe(202); + }); + + it('rejects with 429 and Retry-After once the minute limit is hit', async () => { + const { execute, advance } = makeApp(); + + await execute(); + await execute(); + advance(10_000); + + const response = await execute(); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('50'); + expect(await response.json()).toMatchObject({ code: 'rate_limited', retryAfterSeconds: 50 }); + }); + + it('tracks each IP independently', async () => { + const { execute } = makeApp(); + + await execute('203.0.113.7'); + await execute('203.0.113.7'); + const blocked = await execute('203.0.113.7'); + const otherIp = await execute('198.51.100.9'); + expect(blocked.status).toBe(429); + expect(otherIp.status).toBe(202); + }); + + it('resets the minute window after it elapses', async () => { + const { execute, advance } = makeApp(); + + await execute(); + await execute(); + const blocked = await execute(); + expect(blocked.status).toBe(429); + + advance(MINUTE_MS); + const allowedAgain = await execute(); + expect(allowedAgain.status).toBe(202); + }); + + it('enforces the day limit across minute windows', async () => { + const { execute, advance } = makeApp(); + + for (let index = 0; index < 5; index++) { + const allowed = await execute(); + expect(allowed.status).toBe(202); + advance(MINUTE_MS); + } + + const response = await execute(); + expect(response.status).toBe(429); + // 5 minutes into the day window -> retry once the remaining day elapses + expect(response.headers.get('Retry-After')).toBe(String((DAY_MS - 5 * MINUTE_MS) / 1000)); + }); + + it('resets the day window after it elapses', async () => { + const { execute, advance } = makeApp({ perMinute: 0 }); + + for (let index = 0; index < 5; index++) { + await execute(); + } + const blocked = await execute(); + expect(blocked.status).toBe(429); + + advance(DAY_MS); + const allowedAgain = await execute(); + expect(allowedAgain.status).toBe(202); + }); + + it('uses the first X-Forwarded-For hop as the client identity', async () => { + const { app } = makeApp(); + + const request = (chain: string) => + app.request('/api/workflows/wf-1/execute', { + method: 'POST', + headers: { 'x-forwarded-for': chain }, + }); + + await request('203.0.113.7, 10.0.0.1'); + await request('203.0.113.7, 10.0.0.2'); + const blocked = await request('203.0.113.7, 10.0.0.3'); + expect(blocked.status).toBe(429); + }); +}); diff --git a/apps/backend/src/middleware/rate-limit.ts b/apps/backend/src/middleware/rate-limit.ts new file mode 100644 index 000000000..2a5b4c70f --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.ts @@ -0,0 +1,110 @@ +import { getConnInfo } from '@hono/node-server/conninfo'; +import type { Context, MiddlewareHandler } from 'hono'; + +export type RateLimitOptions = { + // 0 disables a window + perMinute: number; + perDay: number; + // only safe when the backend is reachable exclusively through a proxy that + // sets X-Forwarded-For — a directly reachable backend lets clients spoof it + trustProxy: boolean; + now?: () => number; +}; + +type WindowState = { + windowStart: number; + count: number; +}; + +type IpState = { + minute: WindowState; + day: WindowState; +}; + +const MINUTE_MS = 60_000; +const DAY_MS = 24 * 60 * 60 * 1000; +const SWEEP_INTERVAL_MS = 10 * MINUTE_MS; + +function clientIp(c: Context, trustProxy: boolean): string { + if (trustProxy) { + const forwardedFor = c.req.header('x-forwarded-for'); + const first = forwardedFor?.split(',')[0]?.trim(); + if (first) { + return first; + } + } + try { + return getConnInfo(c).remote.address ?? 'unknown'; + } catch { + // no underlying socket (app.request() in tests) + return 'unknown'; + } +} + +function hitWindow(state: WindowState, limit: number, durationMs: number, now: number): number | null { + if (limit <= 0) { + return null; + } + if (now - state.windowStart >= durationMs) { + state.windowStart = now; + state.count = 0; + } + if (state.count >= limit) { + return state.windowStart + durationMs - now; + } + return null; +} + +// In-memory fixed windows: counters reset on restart and are not shared +// across replicas — fine for the single-replica demo deployment. +export function createRateLimitMiddleware(options: RateLimitOptions): MiddlewareHandler { + const { perMinute, perDay, trustProxy } = options; + const now = options.now ?? Date.now; + const states = new Map(); + let lastSweep = now(); + + return async (c, next) => { + const timestamp = now(); + + if (timestamp - lastSweep >= SWEEP_INTERVAL_MS) { + lastSweep = timestamp; + for (const [ip, state] of states) { + if (timestamp - state.day.windowStart >= DAY_MS && timestamp - state.minute.windowStart >= MINUTE_MS) { + states.delete(ip); + } + } + } + + const ip = clientIp(c, trustProxy); + let state = states.get(ip); + if (!state) { + state = { + minute: { windowStart: timestamp, count: 0 }, + day: { windowStart: timestamp, count: 0 }, + }; + states.set(ip, state); + } + + const minuteRetry = hitWindow(state.minute, perMinute, MINUTE_MS, timestamp); + const dayRetry = hitWindow(state.day, perDay, DAY_MS, timestamp); + const retryAfterMs = Math.max(minuteRetry ?? 0, dayRetry ?? 0); + + if (retryAfterMs > 0) { + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + c.header('Retry-After', String(retryAfterSeconds)); + return c.json( + { + code: 'rate_limited', + message: 'Too many workflow executions from this address — try again later', + retryAfterSeconds, + }, + 429, + ); + } + + state.minute.count += 1; + state.day.count += 1; + + await next(); + }; +} diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 351dc9a81..d29dd2853 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -12,8 +12,10 @@ import { createAuthMiddleware, makeAssertAuthorized, } from './auth'; +import { runMigrations } from './db/migrate'; import { env } from './env'; import { logger } from './logger'; +import { createRateLimitMiddleware } from './middleware/rate-limit'; import { createExecutionsRoutes } from './routes/executions'; import { createWorkflowsRoutes } from './routes/workflows'; import { NoopTenantContextPort, type TenantContextPort, type TenantVariables, createTenantMiddleware } from './tenant'; @@ -60,9 +62,29 @@ app.get('/api/health', (c) => c.json({ status: 'ok' })); app.use('/api/*', createAuthMiddleware(authPort)); app.use('/api/*', createTenantMiddleware(tenantPort)); +if (env.RATE_LIMIT_EXECUTE_PER_MINUTE > 0 || env.RATE_LIMIT_EXECUTE_PER_DAY > 0) { + app.use( + '/api/workflows/:id/execute', + createRateLimitMiddleware({ + perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE, + perDay: env.RATE_LIMIT_EXECUTE_PER_DAY, + trustProxy: env.TRUST_PROXY, + }), + ); + logger.info('execute rate limit enabled', { + perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE, + perDay: env.RATE_LIMIT_EXECUTE_PER_DAY, + trustProxy: env.TRUST_PROXY, + }); +} + app.route('/api/workflows', createWorkflowsRoutes(assertAuthorized)); app.route('/api/executions', createExecutionsRoutes(assertAuthorized)); +// a failure (DB still starting) exits the process; the container restart policy retries +await runMigrations(); +logger.info('database migrations applied'); + 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/package.json b/apps/execution-worker/package.json index 2a3d9aa58..1d394656e 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "tsx watch --env-file=.env ./src/engines/temporal/worker.ts", "start": "tsx --env-file=.env ./src/engines/temporal/worker.ts", + "start:prod": "tsx ./src/engines/temporal/worker.ts", "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix", @@ -19,7 +20,8 @@ "@workflow-builder/execution-core": "workspace:*", "ai": "^6.0.0", "dotenv": "^17.4.2", - "postgres": "^3.4.5" + "postgres": "^3.4.5", + "tsx": "^4.19.3" }, "devDependencies": { "@types/node": "^22.12.0", diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 326ed7c5a..401c60e93 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -1,4 +1,4 @@ -import { Worker } from '@temporalio/worker'; +import { NativeConnection, Worker } from '@temporalio/worker'; import 'dotenv/config'; import { fileURLToPath } from 'node:url'; @@ -42,7 +42,11 @@ const activities = { }, }; +// without an explicit connection, Worker.create dials 127.0.0.1:7233 and ignores TEMPORAL_ADDRESS +const connection = await NativeConnection.connect({ address: env.TEMPORAL_ADDRESS }); + const worker = await Worker.create({ + connection, workflowsPath: fileURLToPath(new URL('workflows/run-workflow.ts', import.meta.url)), activities, taskQueue, diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example new file mode 100644 index 000000000..b80d85932 --- /dev/null +++ b/deploy/ai-studio/.env.example @@ -0,0 +1,36 @@ +# Copy to .env next to docker-compose.yml and fill in. Everything except +# OPENROUTER_API_KEY has a working default. + +# --- required --------------------------------------------------------------- + +# Server-side only; never reaches the browser. Pair it with an OpenRouter +# account Guardrail (hard $/day ceiling) — see README "Spend safety". +OPENROUTER_API_KEY= + +# --- LLM -------------------------------------------------------------------- + +# WB-229 demo model. Cheap, EU-hosted, solid tool calling. +# ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run. +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct + +# --- abuse gate (per-IP, execute route) --------------------------------------- + +RATE_LIMIT_EXECUTE_PER_MINUTE=10 +RATE_LIMIT_EXECUTE_PER_DAY=50 + +# --- network ------------------------------------------------------------------ + +# Where the web container publishes. Put your TLS terminator in front of it +# (or bind 127.0.0.1 and proxy from a host nginx/caddy). +WEB_BIND=0.0.0.0 +WEB_PORT=8080 + +# Leave empty: the SPA then calls /api on its own origin and nginx proxies +# it to the backend (no CORS, SSE intact). Only set this if the frontend is +# served from a different host than the backend. +VITE_BACKEND_URL= + +# --- databases (internal network only, not published) ------------------------- + +APP_DB_PASSWORD=wb +TEMPORAL_DB_PASSWORD=temporal diff --git a/deploy/ai-studio/Dockerfile b/deploy/ai-studio/Dockerfile new file mode 100644 index 000000000..664c6d6ea --- /dev/null +++ b/deploy/ai-studio/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +# Targets: runtime (backend + worker, command chosen per compose service), +# web (nginx, SPA + /api proxy). Build context must be the repo root — +# workspace packages are linked via pnpm `workspace:*`. +# +# Exact Node pin: engineStrict rejects any other version. pnpm via npm, not +# corepack — this Node's corepack cannot load pnpm 10 +# (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Keep in sync with `packageManager`. +FROM node:22.12.0-bookworm-slim AS base +ENV PNPM_HOME=/pnpm \ + PATH="/pnpm:$PATH" \ + # husky needs the .git dir that the build context excludes + HUSKY=0 \ + npm_config_store_dir=/pnpm/store \ + CI=true +RUN npm install -g pnpm@10.17.0 +WORKDIR /app + +FROM base AS source +COPY pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch +COPY . . + +# tsx runs TS directly — required anyway for the worker, whose workflow +# sandbox bundles from TS source on disk at runtime. +# --prefer-offline (not --offline): offline mode leaks into lifecycle +# scripts and breaks the icons build, which shells out to npx. +FROM source AS runtime +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + # `prepare` runs husky, absent from a --prod install + npm pkg delete scripts.prepare && \ + pnpm install --frozen-lockfile --prefer-offline --prod \ + --filter backend... --filter execution-worker... + +# VITE_BACKEND_URL is baked at build time; empty = same-origin /api, +# proxied by the web target's nginx. +FROM source AS frontend-build +ARG VITE_BACKEND_URL= +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --prefer-offline --filter @workflow-builder/ai-studio... +RUN VITE_BACKEND_URL=$VITE_BACKEND_URL pnpm build:ai-studio + +FROM nginx:1.31-alpine AS web +COPY deploy/ai-studio/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=frontend-build /app/dist/apps/ai-studio /usr/share/nginx/html diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md new file mode 100644 index 000000000..d1f4c4959 --- /dev/null +++ b/deploy/ai-studio/README.md @@ -0,0 +1,111 @@ +# Deploying AI Studio + +Self-contained, portable deployment of the AI Studio stack (WB-229). Runs on +any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. + +> Deploying onto the company Swarm cluster instead? See +> [`tools/deployment/`](../../tools/deployment/README.md) — same images, +> Traefik/ACR/Ansible orchestration aligned with the workflow-builder repo. + +## What runs + +| Service | Image | Role | Exposed | +| ------------- | ------------------------------ | ----------------------------------------------- | ------------------------ | +| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | +| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | +| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal | +| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | +| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | +| `temporal-db` | `postgres:16` | Temporal's own state store | internal | +| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | + +Both images build from one Dockerfile (`deploy/ai-studio/Dockerfile`) with the +repo root as context. Backend and worker share a single image and differ only +in the compose `command`. Database migrations are applied by the backend at +boot (drizzle-orm's programmatic migrator) — there is no separate migration +service or step. + +## Quick start + +```bash +cd deploy/ai-studio +cp .env.example .env # set OPENROUTER_API_KEY +docker compose up -d --build +``` + +First boot: the backend applies migrations and only then starts serving (its +healthcheck gates the worker). The worker crash-loops for ~30s until Temporal +finishes auto-setup — that's expected, `restart: unless-stopped` converges it. + +Verify: + +```bash +curl -s http://localhost:8080/api/health # {"status":"ok"} +# open http://localhost:8080, run the "Sales Inquiry Pipeline" template +``` + +## Spend safety (do not skip) + +Two independent controls; both must be in place before the URL goes public: + +1. **OpenRouter Guardrail** (hard $/day ceiling, no code involved): + [openrouter.ai](https://openrouter.ai) → Settings → Guardrails → daily + spend limit, e.g. **$5/day** (resets 00:00 UTC). When hit, OpenRouter + rejects calls and the demo pauses — it cannot overspend. Keep the account + balance low (~$20) as the absolute ceiling. +2. **Per-IP rate limit** (already on in this compose): defaults to 10 + executions/min and 50/day per IP, tunable via + `RATE_LIMIT_EXECUTE_PER_MINUTE` / `RATE_LIMIT_EXECUTE_PER_DAY`. In-memory, + single-replica by design; counters reset on backend restart. + +At the defaults, a worst case full Guardrail day costs $5; a typical +3-LLM-call template run on Mistral Small 3.2 costs ~$0.0004. + +## TLS / going public + +The `web` container speaks plain HTTP on the internal port. Pick one: + +- **Existing ingress** (Azure Application Gateway / Front Door, an nginx that + already routes your other web apps, …): point it at `WEB_PORT`, set + `WEB_BIND=127.0.0.1` if the ingress runs on the same host. SSE caveat: the + ingress must not buffer `/api/executions/*/stream` responses and needs a + read timeout above 60s (the stream heartbeats every 15s). +- **Standalone VM**: run a host-level [Caddy](https://caddyserver.com) + (`reverse_proxy localhost:8080` — automatic Let's Encrypt, SSE-safe out of + the box) or certbot'd nginx in front, and firewall everything except + 80/443. + +Keep 8233 (Temporal UI) and the Postgres ports unreachable from outside — +this compose never publishes them; don't undo that. + +## Configuration + +See [.env.example](.env.example) — every variable is documented there. +Swapping the LLM is a one-liner: change `AI_MODEL` to any +[OpenRouter model id](https://openrouter.ai/models) and +`docker compose up -d worker`. + +## Operations + +```bash +docker compose logs -f backend worker # tail the apps +docker compose --profile debug up -d # Temporal UI on 127.0.0.1:8233 +docker compose up -d --build # deploy a new version (backend re-applies migrations at boot) +docker compose down # stop (volumes survive) +docker exec ai-studio-app-db-1 pg_dump -U wb workflow_builder > backup.sql +``` + +Workflow data is treated as ephemeral for the public demo — losing the +volumes is acceptable; there is nothing precious in them. + +## Known limitations (accepted for the lean MVP) + +- **No login.** The API is open (`WB_AUTH_PORT=allow-all`); anyone with the + URL can create and run workflows within the rate limits. The SDK has an + `AuthPort` seam for wiring real auth later. +- **Single backend replica.** The rate limiter is process-local. Scaling out + needs a shared store (Redis) — deferred to the scale-ready task. +- **`temporalio/auto-setup` is dev-grade.** Fine for a demo; move to Temporal + Cloud or an operated cluster for sustained load. +- **Anyone-can-edit demo content.** Visitors share one workspace; data is + wiped whenever you decide to recreate the volumes. diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml new file mode 100644 index 000000000..b891cea66 --- /dev/null +++ b/deploy/ai-studio/docker-compose.yml @@ -0,0 +1,141 @@ +# AI Studio production stack (WB-229). Usage: cp .env.example .env, set +# OPENROUTER_API_KEY, then `docker compose up -d --build`. Only `web` +# publishes a port. + +name: ai-studio + +x-runtime-build: &runtime-build + context: ../.. + dockerfile: deploy/ai-studio/Dockerfile + target: runtime + +services: + app-db: + image: postgres:16 + environment: + POSTGRES_DB: workflow_builder + POSTGRES_USER: wb + POSTGRES_PASSWORD: ${APP_DB_PASSWORD:-wb} + volumes: + - app-db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'wb', '-d', 'workflow_builder'] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + temporal-db: + image: postgres:16 + environment: + POSTGRES_DB: temporal + POSTGRES_USER: temporal + POSTGRES_PASSWORD: ${TEMPORAL_DB_PASSWORD:-temporal} + volumes: + - temporal-db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'temporal', '-d', 'temporal'] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + # auto-setup is dev-grade; sustained load should move to Temporal Cloud + # or an operated cluster — the apps only consume TEMPORAL_ADDRESS + temporal: + image: temporalio/auto-setup:1.29.6.1 + depends_on: + temporal-db: + condition: service_healthy + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: temporal + POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:-temporal} + POSTGRES_SEEDS: temporal-db + restart: unless-stopped + + temporal-ui: + image: temporalio/ui:2.51.0 + profiles: [debug] + depends_on: + - temporal + environment: + TEMPORAL_ADDRESS: temporal:7233 + ports: + - '127.0.0.1:8233:8080' + restart: unless-stopped + + # applies migrations at boot; on failure exits and `restart` retries + backend: + image: ai-studio-runtime + build: *runtime-build + command: ['pnpm', '--filter', 'backend', 'start:prod'] + environment: + HOST: 0.0.0.0 + PORT: 3001 + DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder + TEMPORAL_ADDRESS: temporal:7233 + # explicit opt-in — a forgotten env var fails loudly instead of exposing the API + WB_AUTH_PORT: allow-all + # only nginx can reach the backend, so X-Forwarded-For is trustworthy + TRUST_PROXY: 'true' + RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10} + RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50} + depends_on: + app-db: + condition: service_healthy + temporal: + condition: service_started + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:3001/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s + restart: unless-stopped + + # crash-loops until Temporal answers (no usable healthcheck); restart converges it + worker: + image: ai-studio-runtime + build: *runtime-build + command: ['pnpm', '--filter', 'execution-worker', 'start:prod'] + environment: + DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder + TEMPORAL_ADDRESS: temporal:7233 + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} + AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + depends_on: + app-db: + condition: service_healthy + # backend healthy = migrations applied + backend: + condition: service_healthy + temporal: + condition: service_started + restart: unless-stopped + + web: + image: ai-studio-web + build: + context: ../.. + dockerfile: deploy/ai-studio/Dockerfile + target: web + args: + # empty -> SPA calls /api on its own origin via this nginx + VITE_BACKEND_URL: ${VITE_BACKEND_URL:-} + ports: + - '${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8080}:80' + depends_on: + - backend + restart: unless-stopped + +volumes: + app-db-data: + temporal-db-data: diff --git a/deploy/ai-studio/nginx/default.conf b/deploy/ai-studio/nginx/default.conf new file mode 100644 index 000000000..501c28d4f --- /dev/null +++ b/deploy/ai-studio/nginx/default.conf @@ -0,0 +1,58 @@ +# AI Studio — SPA + /api reverse proxy; the stack's only public surface. +# TLS terminates in front (see README.md). + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Resolve the backend through Docker's embedded DNS on every request + # (via the variable indirection below) instead of once at startup — + # otherwise recreating the backend container leaves nginx proxying to a + # stale IP and every /api call 502s until this container restarts too. + resolver 127.0.0.11 valid=10s ipv6=off; + set $backend_upstream http://backend:3001; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # backend enforces 1 MB with a structured error — stay above it + client_max_body_size 2m; + + # SSE: never buffer, outlast the 15s heartbeat + location ~ ^/api/executions/.+/stream$ { + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + gzip off; + } + + location /api/ { + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # content-hashed filenames — cache forever + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # SPA fallback + location / { + try_files $uri /index.html; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c154bab6..f2ec0e8c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: postgres: specifier: ^3.4.5 version: 3.4.9 + tsx: + specifier: ^4.19.3 + version: 4.21.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -366,6 +369,9 @@ importers: postgres: specifier: ^3.4.5 version: 3.4.9 + tsx: + specifier: ^4.19.3 + version: 4.21.0 devDependencies: '@types/node': specifier: ^22.12.0 From 1efd13c012bd16af9d327cbf3913a4be8cdfc3d5 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Thu, 2 Jul 2026 10:53:16 +0200 Subject: [PATCH 02/14] fix(sdk): language selector reflects resolved language for regional locales (#58) The selector matched the current option against the raw i18n.language, so a regional locale (e.g. pl-PL) - which equals neither 'en' nor 'pl' - fell back to the first option and displayed 'EN' while the UI rendered Polish. Match on i18n.resolvedLanguage (base language) instead. Adds a regression spec. Co-authored-by: Jan Librowski --- .../fix-language-selector-regional-locale.md | 5 +++ .../language-selector.spec.tsx | 43 +++++++++++++++++++ .../language-selector/language-selector.tsx | 3 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-language-selector-regional-locale.md create mode 100644 packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx diff --git a/.changeset/fix-language-selector-regional-locale.md b/.changeset/fix-language-selector-regional-locale.md new file mode 100644 index 000000000..68a5bb5e8 --- /dev/null +++ b/.changeset/fix-language-selector-regional-locale.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +Fix the language selector displaying "EN" while the UI rendered Polish for regional locales (e.g. `pl-PL`). The selector now resolves the current option from the base/resolved language instead of the raw `i18n.language`, so its label reflects the language the strings actually render in. diff --git a/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx b/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx new file mode 100644 index 000000000..4064745f1 --- /dev/null +++ b/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// Controllable i18n stub — each test sets `language` / `resolvedLanguage`. +const i18nState = { language: 'en', resolvedLanguage: 'en', changeLanguage: vi.fn() }; + +// Render the Menu's trigger (children) so the displayed language code is queryable. +vi.mock('@synergycodes/overflow-ui', () => ({ + Menu: ({ children }: { children?: ReactNode }) =>
{children}
, + NavButton: ({ children }: { children?: ReactNode }) => , +})); + +vi.mock('@workflow-builder/icons', () => ({ + Icon: () => null, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: i18nState }), +})); + +const { LanguageSelector } = await import('./language-selector'); + +describe('LanguageSelector — label reflects the resolved language', () => { + it('shows PL for a regional Polish locale that resolves to pl (regression: used to show EN)', () => { + i18nState.language = 'pl-PL'; + i18nState.resolvedLanguage = 'pl'; + + render(); + + expect(screen.getByText('PL')).toBeTruthy(); + expect(screen.queryByText('EN')).toBeNull(); + }); + + it('shows EN for english', () => { + i18nState.language = 'en'; + i18nState.resolvedLanguage = 'en'; + + render(); + + expect(screen.getByText('EN')).toBeTruthy(); + }); +}); diff --git a/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx b/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx index b46f73fc0..498b1279f 100644 --- a/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx +++ b/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx @@ -20,7 +20,8 @@ const languages: Language[] = [ export function LanguageSelector() { const { t, i18n } = useTranslation(); - const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0]; + const resolvedCode = i18n.resolvedLanguage ?? i18n.language?.split('-')[0]; + const currentLanguage = languages.find((lang) => lang.code === resolvedCode) || languages[0]; const languageItems: MenuItemProps[] = useMemo( () => From d254b939281828043b6ec184a1124e3fc6a74d0a Mon Sep 17 00:00:00 2001 From: Jakub Kubacki Date: Thu, 2 Jul 2026 14:35:17 +0200 Subject: [PATCH 03/14] feat(deploy): Add GitHub Action automation (#59) --- .github/workflows/deploy-ai-studio.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-ai-studio.yml b/.github/workflows/deploy-ai-studio.yml index 8b87e52d3..0a1850c10 100644 --- a/.github/workflows/deploy-ai-studio.yml +++ b/.github/workflows/deploy-ai-studio.yml @@ -1,8 +1,6 @@ name: Deploy AI Studio on: - push: - branches: ["WB-229-swarm-alignment"] workflow_dispatch: inputs: image_tag: @@ -86,17 +84,18 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - name: Install Azure SSH extension - run: az extension add --name ssh --yes - - name: Refresh docker compose on Azure VM run: | - az ssh vm \ + OUTPUT=$(az vm run-command invoke \ --name ${{ vars.AI_STUDIO_VM_NAME }} \ --resource-group ${{ vars.AI_STUDIO_VM_RG }} \ - --command " + --command-id RunShellScript \ + --scripts ' set -e az acr login --name synergycodes - docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml pull - docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml up -d --no-build --force-recreate - " + docker compose -f /app/ai-studio/docker-compose.yml pull + docker compose -f /app/ai-studio/docker-compose.yml up -d --no-build --force-recreate + echo DEPLOY_SCRIPT_SUCCEEDED + ') + echo "$OUTPUT" + echo "$OUTPUT" | grep -q DEPLOY_SCRIPT_SUCCEEDED From f3deb96bbf47a2578d44daca1b1aff026fd116d6 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Thu, 2 Jul 2026 16:45:41 +0200 Subject: [PATCH 04/14] fix(sdk): decision-node ports and panel spacing, TextArea maxRows (#60) * fix(sdk): drop the decision node's outer output port when branches exist Each branch renders its own source handle, so the node-level source port was a redundant, disconnected port on any decision node with branches. Branchless decision nodes keep it (existing diagrams route edges through it). * feat(sdk): forward maxRows on the TextArea uischema control overflow-ui's TextArea already supports it; the uischema type and control only exposed minRows, so consumers had to cap growth with CSS max-height. * fix(sdk): space out branch cards in the decision properties panel DecisionBranchesControl wrapped its cards and the add-branch button in an unstyled div, so they rendered glued together. Style the wrapper as a column with a gap, following the component-token pattern. * refactor(sdk): drop the accordion's classless-div layout hack The accordion content styled bare 'div:not([class])' children to compensate for controls with unstyled roots. DecisionBranches - the only such control - now styles its own wrapper with the same 0.75rem gap, so the selector had no remaining in-repo target and only masked missing control styling. * fix(sdk): remove the decision node's source handle entirely Execution routes only through branch handles - the decision executor sets nextPort to the matched branch or fails with no_branch_matched, so an edge from the bare source handle can never fire. Keeping it for branchless nodes only invited connections that silently dead-end; the demo's canned decision nodes are unaffected (they render as plain nodes, not this template). * docs: changesets are 1-2 sentence changelog entries Codify the rule in CLAUDE.md and tighten this branch's changesets (plus the verbose language-selector one already on main) to match: the consumer-visible change and any migration note only - rationale lives in commits and PRs. --------- Co-authored-by: Jan Librowski --- .changeset/decision-branches-panel-spacing.md | 5 +++ .../decision-node-outer-source-handle.md | 5 +++ .../fix-language-selector-regional-locale.md | 2 +- .changeset/textarea-max-rows.md | 5 +++ CLAUDE.md | 2 +- .../decision-node-template.tsx | 4 -- .../decision-branches-control.module.css | 9 ++++ .../decision-branches-control.spec.tsx | 43 +++++++++++++++++++ .../decision-branches-control.tsx | 4 +- .../text-area-control/text-area-control.tsx | 3 +- .../accordion-layout.module.css | 3 +- packages/sdk/src/types/controls.ts | 2 +- 12 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 .changeset/decision-branches-panel-spacing.md create mode 100644 .changeset/decision-node-outer-source-handle.md create mode 100644 .changeset/textarea-max-rows.md create mode 100644 packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.module.css create mode 100644 packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.spec.tsx diff --git a/.changeset/decision-branches-panel-spacing.md b/.changeset/decision-branches-panel-spacing.md new file mode 100644 index 000000000..a89f37da3 --- /dev/null +++ b/.changeset/decision-branches-panel-spacing.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +Branch cards in the decision properties panel are now vertically spaced. The accordion layout no longer lays out classless child divs - custom controls should style their own root. diff --git a/.changeset/decision-node-outer-source-handle.md b/.changeset/decision-node-outer-source-handle.md new file mode 100644 index 000000000..d57f9266a --- /dev/null +++ b/.changeset/decision-node-outer-source-handle.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +The decision node no longer renders a node-level output port - branches are its only outputs. Edges drawn from the old bare `source` handle lose their connection point. diff --git a/.changeset/fix-language-selector-regional-locale.md b/.changeset/fix-language-selector-regional-locale.md index 68a5bb5e8..0f716e20d 100644 --- a/.changeset/fix-language-selector-regional-locale.md +++ b/.changeset/fix-language-selector-regional-locale.md @@ -2,4 +2,4 @@ '@workflowbuilder/sdk': patch --- -Fix the language selector displaying "EN" while the UI rendered Polish for regional locales (e.g. `pl-PL`). The selector now resolves the current option from the base/resolved language instead of the raw `i18n.language`, so its label reflects the language the strings actually render in. +The language selector no longer shows "EN" while the UI renders Polish for regional locales (e.g. `pl-PL`). diff --git a/.changeset/textarea-max-rows.md b/.changeset/textarea-max-rows.md new file mode 100644 index 000000000..de01a88ce --- /dev/null +++ b/.changeset/textarea-max-rows.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +The `TextArea` uischema control accepts `maxRows`, capping how far the field auto-grows. diff --git a/CLAUDE.md b/CLAUDE.md index 8e389a206..b7e76c160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ The SDK is the only npm-published workspace; everything else under `apps/` and ` **Daily SDK change:** 1. Edit `packages/sdk/**`, run tests/typecheck locally. -2. **Add a changeset** with `/wb.changeset ""`. Required for any consumer-visible change. Skip only for changes that don't ship in `dist/` (e.g. `eslint.config.mjs`, internal tests, source-only comments). +2. **Add a changeset** with `/wb.changeset ""`. Required for any consumer-visible change. Skip only for changes that don't ship in `dist/` (e.g. `eslint.config.mjs`, internal tests, source-only comments). Keep it to 1-2 plain sentences: the consumer-visible change plus any migration note - changesets become the public CHANGELOG, so no rationale, investigation history, or noise (that belongs in the commit message and PR). 3. Commit code + changeset together with a Conventional Commits message: ``` git add packages/sdk/... .changeset/.md diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx index 03a6d2e1a..b3d8638e7 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx @@ -45,10 +45,7 @@ export const DecisionNodeTemplate = memo( const iconElement = useMemo(() => , [icon]); const handleTargetId = getHandleId({ handleType: 'target' }); - const handleSourceId = getHandleId({ handleType: 'source' }); - const handleTargetPosition = getHandlePosition({ direction: layoutDirection, handleType: 'target' }); - const handleSourcePosition = getHandlePosition({ direction: layoutDirection, handleType: 'source' }); const isCanvasNode = showHandles; @@ -72,7 +69,6 @@ export const DecisionNodeTemplate = memo( - ); diff --git a/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.module.css b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.module.css new file mode 100644 index 000000000..c630f3f31 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.module.css @@ -0,0 +1,9 @@ +:root { + --wb-decision-branches-gap: 0.75rem; +} + +.branches { + display: flex; + flex-direction: column; + gap: var(--wb-decision-branches-gap); +} diff --git a/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.spec.tsx b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.spec.tsx new file mode 100644 index 000000000..6f0d47857 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.spec.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./branch-card/branch-card', () => ({ + BranchCard: () =>
, +})); + +vi.mock('../../../diagram/nodes/components/placeholder-button/placeholder-button', () => ({ + PlaceholderButton: () => , +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Unwrap withJsonFormsControlProps so props flow directly instead of from JSONForms context. +vi.mock('../../utils/rendering', () => ({ + createControlRenderer: (_type: string, renderer: unknown) => ({ tester: undefined, renderer }), +})); + +const { decisionBranchesControlRenderer } = await import('./decision-branches-control'); + +const branch = (id: string) => ({ id, sourceHandle: `source:inner:${id}`, label: id, conditions: [] }); + +describe('DecisionBranchesControl', () => { + it('stacks branch cards in a styled container (regression: unstyled div rendered them glued)', () => { + const Control = decisionBranchesControlRenderer.renderer as unknown as React.ComponentType>; + const { container, getAllByTestId } = render( + {}} + path="decisionBranches" + enabled={true} + errors="" + uischema={{ type: 'DecisionBranches', scope: '#/properties/decisionBranches' }} + />, + ); + + const wrapper = getAllByTestId('branch-card')[0].parentElement; + expect(wrapper?.className).toContain('branches'); + expect(container.querySelectorAll('[data-testid="branch-card"]')).toHaveLength(2); + }); +}); diff --git a/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.tsx b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.tsx index a1cadcf39..b0616ccc2 100644 --- a/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.tsx +++ b/packages/sdk/src/features/json-form/controls/decision-branches-control/decision-branches-control.tsx @@ -1,6 +1,8 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import styles from './decision-branches-control.module.css'; + import { PlaceholderButton } from '../../../diagram/nodes/components/placeholder-button/placeholder-button'; import type { DecisionBranch, DecisionBranchesControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; @@ -38,7 +40,7 @@ function DecisionBranchesControl(props: DecisionBranchesControlProps) { } return ( -
+
{decisionBranches.map((branch, index) => ( (data); @@ -30,6 +30,7 @@ function TextAreaControl(props: TextAreaControlProps) { disabled={isDisabled} value={inputValue} minRows={minRows} + maxRows={maxRows} placeholder={placeholder} onChange={onChange} onBlur={onBlur} diff --git a/packages/sdk/src/features/json-form/layouts/accordion-layout/accordion-layout.module.css b/packages/sdk/src/features/json-form/layouts/accordion-layout/accordion-layout.module.css index ded620655..20380f7b7 100644 --- a/packages/sdk/src/features/json-form/layouts/accordion-layout/accordion-layout.module.css +++ b/packages/sdk/src/features/json-form/layouts/accordion-layout/accordion-layout.module.css @@ -1,5 +1,4 @@ -.accordion-content, -.accordion-content > div:not([class]) { +.accordion-content { display: flex; flex-direction: column; gap: 0.75rem; diff --git a/packages/sdk/src/types/controls.ts b/packages/sdk/src/types/controls.ts index a6e5993a7..6259b277c 100644 --- a/packages/sdk/src/types/controls.ts +++ b/packages/sdk/src/types/controls.ts @@ -37,7 +37,7 @@ export type TextAreaControlElement = Override< BaseControlElement, { type: 'TextArea'; - } & Pick + } & Pick >; export type TextAreaControlProps = ControlProps; From ba8e87936cbf375848b5c1998652553eb22efbf3 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 3 Jul 2026 13:17:02 +0200 Subject: [PATCH 05/14] feat(sdk): logo and logoHref props on WorkflowBuilder.Root (#61) The app bar hardcoded the Workflow Builder logo with no way to replace or link it, forcing consumers into hash-prefix CSS hides and fixed-position overlays. The props follow the existing render-body config-holder pattern. Co-authored-by: Jan Librowski --- .changeset/app-bar-logo-prop.md | 5 ++ .../docs/guides/configuring-the-editor.md | 2 + packages/sdk/README.md | 2 + packages/sdk/src/data/app-bar-branding.ts | 16 ++++++ .../src/features/app-bar/app-bar.module.css | 24 ++++++++ .../components/toolbar/toolbar.spec.tsx | 57 +++++++++++++++++++ .../app-bar/components/toolbar/toolbar.tsx | 34 ++++++++++- packages/sdk/src/index.ts | 1 + .../sdk/src/workflow-builder-root/index.ts | 1 + .../workflow-builder-root.tsx | 4 ++ .../workflow-builder-root.types.ts | 17 +++++- 11 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 .changeset/app-bar-logo-prop.md create mode 100644 packages/sdk/src/data/app-bar-branding.ts create mode 100644 packages/sdk/src/features/app-bar/components/toolbar/toolbar.spec.tsx diff --git a/.changeset/app-bar-logo-prop.md b/.changeset/app-bar-logo-prop.md new file mode 100644 index 000000000..23262f06b --- /dev/null +++ b/.changeset/app-bar-logo-prop.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +`` accepts `logo` (an image URL, `{ light, dark }` per-theme URLs, or a custom element - replaces the built-in app-bar logo) and `logoHref` (wraps it in a link). diff --git a/apps/docs/src/content/docs/guides/configuring-the-editor.md b/apps/docs/src/content/docs/guides/configuring-the-editor.md index 48b22c8ea..90ef289e3 100644 --- a/apps/docs/src/content/docs/guides/configuring-the-editor.md +++ b/apps/docs/src/content/docs/guides/configuring-the-editor.md @@ -27,6 +27,8 @@ Every prop is optional. The **Type** column links to the auto-generated [API Ref | `jsonForm` | [`WorkflowBuilderJsonFormConfig`](/api/plugins/workflowbuilderjsonformconfig/) | Custom JSONForms renderers, cells, and translations for the properties panel. See [Custom JsonForms control](/guides/custom-jsonforms-control/). | | `plugins` | [`WorkflowBuilderPlugin[]`](/api/plugins/workflowbuilderplugin/) | Plugin initializer functions, each called once on first mount. See [Build a plugin](/guides/build-a-plugin/). | | `name` | `string` | Workflow name shown in the header and included in saved data. | +| `logo` | `WorkflowBuilderLogo` | Replaces the built-in app-bar logo: an image URL, `{ light, dark }` per-theme URLs, or a custom element. | +| `logoHref` | `string` | Wraps the app-bar logo (built-in or custom) in a link opened in a new tab. | | `layoutDirection` | [`LayoutDirection`](/api/types/layoutdirection/) | Initial flow direction, `'DOWN'` or `'RIGHT'`. Defaults to `'DOWN'`. | | `initialNodes` | [`WorkflowBuilderNode[]`](/api/types/workflowbuildernode/) | Initial nodes for the `props` integration strategy. Defaults to `[]`. See [`props`](#props). | | `initialEdges` | [`WorkflowBuilderEdge[]`](/api/types/workflowbuilderedge/) | Initial edges for the `props` integration strategy. Defaults to `[]`. See [`props`](#props). | diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ef4c39aef..63246491e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -108,6 +108,8 @@ If you omit ``, use [`useWorkflowBuilderActions()`](ht | `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. | | `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. | | `name` | `string` | Workflow name shown in the header. | +| `logo` | `WorkflowBuilderLogo` | Replaces the built-in app-bar logo: an image URL, `{ light, dark }` per-theme URLs, or a custom element. | +| `logoHref` | `string` | Wraps the app-bar logo in a link opened in a new tab. | | `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. | | `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | | `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | diff --git a/packages/sdk/src/data/app-bar-branding.ts b/packages/sdk/src/data/app-bar-branding.ts new file mode 100644 index 000000000..57183a00d --- /dev/null +++ b/packages/sdk/src/data/app-bar-branding.ts @@ -0,0 +1,16 @@ +import type { WorkflowBuilderLogo } from '../workflow-builder-root/workflow-builder-root.types'; + +export type AppBarBranding = { + logo?: WorkflowBuilderLogo; + logoHref?: string; +}; + +let branding: AppBarBranding = {}; + +export function setAppBarBranding(value: AppBarBranding): void { + branding = value; +} + +export function getAppBarBranding(): AppBarBranding { + return branding; +} diff --git a/packages/sdk/src/features/app-bar/app-bar.module.css b/packages/sdk/src/features/app-bar/app-bar.module.css index 34795df48..bff34f802 100644 --- a/packages/sdk/src/features/app-bar/app-bar.module.css +++ b/packages/sdk/src/features/app-bar/app-bar.module.css @@ -19,6 +19,12 @@ color: var(--wb-app-bar-logo-color); } + .logo-link { + display: inline-flex; + align-items: center; + color: inherit; + } + .nav-segment { display: flex; align-items: center; @@ -79,3 +85,21 @@ margin-left: 0.5rem; } } + +.logo-image { + display: block; + max-height: 1.5rem; + width: auto; +} + +.logo-image--dark { + display: none; +} + +html[data-theme='dark'] .logo-image--light { + display: none; +} + +html[data-theme='dark'] .logo-image--dark { + display: block; +} diff --git a/packages/sdk/src/features/app-bar/components/toolbar/toolbar.spec.tsx b/packages/sdk/src/features/app-bar/components/toolbar/toolbar.spec.tsx new file mode 100644 index 000000000..4d9898e5f --- /dev/null +++ b/packages/sdk/src/features/app-bar/components/toolbar/toolbar.spec.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../integration/components/save-button/save-button', () => ({ + SaveButton: () => null, +})); + +vi.mock('../../../plugins-core/components/app/optional-app-bar-toolbar', () => ({ + OptionalAppBarTools: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); + +vi.mock('../../../../assets/workflow-builder-logo.svg?react', () => ({ + default: () => , +})); + +const { setAppBarBranding } = await import('../../../../data/app-bar-branding'); +const { Toolbar } = await import('./toolbar'); + +afterEach(() => { + setAppBarBranding({}); +}); + +describe('Toolbar branding', () => { + it('renders the built-in logo by default', () => { + render(); + + expect(screen.getByTestId('built-in-logo')).toBeTruthy(); + }); + + it('renders a custom logo wrapped in a link when branding is set', () => { + setAppBarBranding({ logo: Acme, logoHref: 'https://acme.test' }); + + render(); + + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('https://acme.test'); + expect(screen.getByAltText('Acme')).toBeTruthy(); + expect(screen.queryByTestId('built-in-logo')).toBeNull(); + }); + + it('treats a string logo as an image URL', () => { + setAppBarBranding({ logo: '/brand.svg' }); + + const { container } = render(); + + expect(container.querySelector('img')?.getAttribute('src')).toBe('/brand.svg'); + }); + + it('renders both theme variants for a light/dark logo', () => { + setAppBarBranding({ logo: { light: '/brand-light.svg', dark: '/brand-dark.svg' } }); + + const { container } = render(); + + const sources = Array.from(container.querySelectorAll('img'), (img) => img.getAttribute('src')); + expect(sources).toEqual(['/brand-light.svg', '/brand-dark.svg']); + }); +}); diff --git a/packages/sdk/src/features/app-bar/components/toolbar/toolbar.tsx b/packages/sdk/src/features/app-bar/components/toolbar/toolbar.tsx index bf4f6b612..bd31949b1 100644 --- a/packages/sdk/src/features/app-bar/components/toolbar/toolbar.tsx +++ b/packages/sdk/src/features/app-bar/components/toolbar/toolbar.tsx @@ -1,13 +1,45 @@ +import { isValidElement } from 'react'; +import type { ReactNode } from 'react'; + import styles from '../../app-bar.module.css'; import Logo from '../../../../assets/workflow-builder-logo.svg?react'; +import { getAppBarBranding } from '../../../../data/app-bar-branding'; +import type { WorkflowBuilderLogo } from '../../../../workflow-builder-root/workflow-builder-root.types'; import { SaveButton } from '../../../integration/components/save-button/save-button'; import { OptionalAppBarTools } from '../../../plugins-core/components/app/optional-app-bar-toolbar'; +function renderLogo(logo: WorkflowBuilderLogo | undefined): ReactNode { + if (logo == null) { + return ; + } + if (typeof logo === 'string') { + return ; + } + if (!isValidElement(logo)) { + return ( + <> + + + + ); + } + return logo; +} + export function Toolbar() { + const { logo: customLogo, logoHref } = getAppBarBranding(); + const logo = renderLogo(customLogo); + return (
- + {logoHref ? ( + + {logo} + + ) : ( + logo + )}
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1e02d9c25..da7ffda03 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -54,6 +54,7 @@ export type { WorkflowBuilderPlugin, WorkflowBuilderIntegration, WorkflowBuilderJsonFormConfig, + WorkflowBuilderLogo, WorkflowBuilderNodeTemplates, WorkflowBuilderEdgeTemplates, WorkflowBuilderIsValidConnection, diff --git a/packages/sdk/src/workflow-builder-root/index.ts b/packages/sdk/src/workflow-builder-root/index.ts index 5f80a946a..c3e47d968 100644 --- a/packages/sdk/src/workflow-builder-root/index.ts +++ b/packages/sdk/src/workflow-builder-root/index.ts @@ -5,6 +5,7 @@ export type { WorkflowBuilderIsValidConnection, WorkflowBuilderIsValidConnectionParams, WorkflowBuilderJsonFormConfig, + WorkflowBuilderLogo, WorkflowBuilderNodeTemplates, WorkflowBuilderPlugin, WorkflowBuilderReactFlowProps, diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx index 61cf7c451..d4e830019 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef } from 'react'; import { registerPluginTranslation } from '../features/plugins-core/adapters/adapter-i18n'; +import { setAppBarBranding } from '../data/app-bar-branding'; import { setCustomEdgeTemplates } from '../data/edge-templates'; import { setCustomNodeTemplates } from '../data/node-templates'; import { setCustomPaletteNodes } from '../data/palette'; @@ -56,6 +57,8 @@ export function WorkflowBuilderRoot({ jsonForm, integration, name, + logo, + logoHref, layoutDirection, initialNodes, initialEdges, @@ -131,6 +134,7 @@ export function WorkflowBuilderRoot({ setCustomEdgeTemplates(edgeTemplates ?? null); setIsValidConnection(isValidConnection ?? null); setReactFlowProps(reactFlowProps ?? null); + setAppBarBranding({ logo, logoHref }); const { strategy, endpoints, onDataSave } = resolveIntegration(integration); diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts index 7943ab0c7..5c15efbd7 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts @@ -1,5 +1,5 @@ import type { Connection, EdgeProps, ReactFlowProps } from '@xyflow/react'; -import type { ComponentType, PropsWithChildren } from 'react'; +import type { ComponentType, PropsWithChildren, ReactElement } from 'react'; import type { WorkflowNodeTemplateProps } from '../features/diagram/nodes/workflow-node-template/workflow-node-template'; import type { @@ -173,6 +173,14 @@ export type WorkflowBuilderReactFlowProps = Omit< AssertAssignable> >; +/** + * App-bar logo accepted by ``: an image URL, per-theme + * image URLs, or a custom element. + * + * @category Core + */ +export type WorkflowBuilderLogo = string | { light: string; dark: string } | ReactElement; + /** * Props accepted by ``. * @@ -224,6 +232,13 @@ export type WorkflowBuilderRootProps = PropsWithChildren<{ integration?: WorkflowBuilderIntegration; /** Workflow name displayed in the app bar and persisted with the diagram. */ name?: string; + /** + * Replaces the built-in Workflow Builder logo in the app bar: an image URL, + * `{ light, dark }` per-theme image URLs, or a custom element rendered as-is. + */ + logo?: WorkflowBuilderLogo; + /** Wraps the app-bar logo (built-in or custom) in a link opened in a new tab. */ + logoHref?: string; /** Initial layout direction (`'RIGHT'` or `'DOWN'`). */ layoutDirection?: LayoutDirection; /** Initial nodes rendered on first mount. */ From d85a9b414b616efbc200c69dd078ef3989a78ba7 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 3 Jul 2026 13:27:47 +0200 Subject: [PATCH 06/14] feat(ai-studio): public AI workflow demo - Visualize node, agent web search, richer templates (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-studio): auto-load flagship template and add first-visit disclaimer Replace the internal Synergy sales-inquiry template with a generic, relatable Customer Support Triage flagship (classify -> route by type -> specialist draft -> QA), built on the existing trigger/agent/decision nodes. Auto-load it on first visit via initialNodes/initialEdges on WorkflowBuilder.Root so visitors land on a runnable workflow instead of a blank canvas; a returning visitor's saved diagram still wins. Add a dismissible first-visit disclaimer modal stating the workflows run real OpenRouter calls, that the demo is not a model benchmark, and that its purpose is to showcase Workflow Builder. * chore(execution-worker): default to gemini-2.5-flash-lite for the demo Cheaper, fast default for the public demo (roughly an order of magnitude cheaper than claude-3.5-haiku). Quality-per-cost is what matters here - the model is the engine, not the product. Override with the AI_MODEL env var. * feat(backend): gate workflow execution with rate limit and Turnstile POST /api/workflows/:id/execute is the only endpoint that spends real LLM budget, so it gets a per-IP fixed-window rate limit plus an optional Cloudflare Turnstile check, applied right after authorization via guardExecution(). Both controls are no-ops when unconfigured (TURNSTILE_SECRET_KEY empty), so local dev runs unprotected; the rate limit is active out of the box as a budget backstop. Turnstile verification fails closed on a verifier error. * feat(ai-studio): attach Turnstile token to workflow execution Before the execute request, fetch a Turnstile token from an invisible widget and send it as the cf-turnstile-token header. Degrades gracefully: with no VITE_TURNSTILE_SITE_KEY the token is undefined, no header is sent, and the backend skips verification - so local dev needs no keys. * feat(ai-studio): add AI Debate, Content Repurposer and Meeting Notes templates Grow the picker into a small gallery of relatable, runnable examples beyond the flagship: AI Debate (parallel personas that fan out then converge on a verdict), Content Repurposer (one post fanned out to three channel variants), and Meeting Notes to Action Items (a linear summarize -> extract -> format chain). All use the existing trigger/agent/decision nodes. * refactor(ai-studio): rename markdown-preview node to visualize Generalize the on-canvas preview node from markdown-only to a future generic Visualizer: type ai-studio/markdown-preview -> ai-studio/visualize, palette label Visualize (Eye icon), worker domain VisualizeNode + executeVisualize + registry, flagship node preview-1 -> visualize-1. Rendering is still markdown for now; renderer set, format detection, charts, diagrams, export and expand land in the following commits. Includes the visualize plan and long-work backlog. * feat(ai-studio): detect output format for visualize Heuristic detectFormat(text) -> {renderer, data?, chartable?} mapping an output string to a renderer for the Visualize node's auto mode: mermaid->diagram, JSON->{chart for {label,value}/{type,data}, table for object arrays, stat-cards for flat scalar objects, json for nested}, CSV->table, else markdown. Conservative (chart/diagram only on clear signals), prose falls back to markdown. 11 unit tests. * feat(ai-studio): add mode param to visualize node Adds a 'Render as' select to the Visualize node: VISUALIZE_MODES = auto + markdown/text/json/table/stat-cards/chart/diagram, default auto. Auto detects the format; the rest force a specific renderer. * feat(ai-studio): json/table/text/stat-card renderers for visualize Add a renderer registry (getRenderer) with markdown, text, JSON tree (collapsible, hand-rolled), table (JSON array / CSV rows), and stat-cards (flat object) renderers. The visualize card now runs detectFormat (or the node's mode), picks the renderer, shows an 'Auto > X' badge, and renders into a framed body. chart and diagram fall back to table/text for now (real renderers land next). * feat(ai-studio): always-on visualize card with empty state The visualize card now renders for the node at all times with a fixed minimum size: an empty-state placeholder ('The visualization appears here after you run the workflow') before a run, a generating indicator while running, and the rendered output on completion. The reveal animation moved from the card frame to the content so it plays when the result arrives, not on every render. * feat(ai-studio): chart renderer (recharts) for visualize Add a lazy-loaded recharts renderer (bar/line/area/pie) for the chart mode and chart-spec envelopes ({type, data}) or {label,value}/{x,y} arrays. recharts only loads when a chart is shown (React.lazy + Suspense). The card shows a 'Try as chart' chip when auto-detected data is chartable but rendered as a table. * feat(ai-studio): mermaid diagram renderer for visualize Add a lazy-loaded mermaid renderer for the diagram mode: it renders a mermaid source string to SVG in the browser (securityLevel strict), and falls back to the raw text on a syntax error. mermaid (~150KB) only loads when a diagram is shown (React.lazy). * feat(ai-studio): export visualize output (png/svg/copy) Add export-visualization util (html-to-image): download PNG, copy image to clipboard (with a download fallback on browsers like Firefox that cannot write image blobs), copy source text, and download SVG (native serialization fast-path for chart/diagram). The card header now offers Copy image / Download PNG / Copy source actions over the rendered content. * feat(ai-studio): expand visualize to fullscreen modal Add an Expand action that opens the visualization full-size in a modal (rendered through a portal to document.body so its fixed overlay escapes the React Flow viewport transform). The modal renders the same renderer larger and offers PNG / SVG / copy-image / copy-source export. * docs(ai-studio): finalize visualize backlog and refresh getRenderer comment All visualize work items complete and verified (test/typecheck/lint/build). chart/diagram visual smoke deferred (needs a structured upstream); covered by unit tests + build (lazy chunks confirmed). * feat(ai-studio): render visualize output inside the node body Move the visualization from a detached floating panel into the node itself: the content renders in-flow inside the node body (via OptionalNodeContent), so the node grows vertically to contain it and reads as one cohesive card. Drops the redundant card title (the node header already shows it) in favor of a slim badge + export/expand toolbar; the body scrolls when the output is long. Empty state and the expand modal are unchanged. * fix(ai-studio): stop mermaid error graphics on non-diagram input Two causes of the giant 'Syntax error in text / mermaid version' graphics: - The diagram renderer called mermaid.render on invalid input, and mermaid injects its error graphic into the DOM on failure. Now it validates with mermaid.parse({ suppressErrors: true }) first and falls back to raw text (with a note) without ever calling render, so nothing is injected. - Auto-detection matched a bare leading word (graph/pie/timeline/journey), so prose starting with those was mis-detected as a diagram. Detection is now strict: a fenced ```mermaid block, or flowchart/graph WITH a direction, or a distinctive declaration keyword. Prose stays markdown. (+3 tests, 13 total) * feat(ai-studio): render embedded mermaid/json blocks in visualize Adapt mixed output to the right format without an LLM: the markdown renderer now renders a fenced ```mermaid block as a real diagram and a ```json block via the detected renderer (chart/table/...), so a markdown response with an embedded diagram renders it inline instead of as raw code. The diagram and chart renderers also extract a fenced block from a larger response when their mode is forced. * feat(ai-studio): AI adapt - LLM-convert output to the chosen format Adds an opt-in 'AI adapt' action on the Visualize node: it sends the upstream output to a new backend endpoint (POST /api/visualize/adapt) that uses an LLM to convert it into the active format (clean Mermaid / chart JSON / table / ...), then renders the result. The endpoint reuses the execution abuse gate (per-IP rate limit + optional Turnstile) and is disabled (501) when the backend has no OPENROUTER_API_KEY. Verified end-to-end: action-items prose -> clean mermaid. * feat(ai-studio): add aiAdapt toggle to visualize node config Adds an 'AI: adapt output to this format' Switch to the Visualize node's properties panel. When on, the node automatically LLM-converts the upstream output into the active render format on each run (no need to click the on-card button, which is hidden while the toggle is on). Off keeps the manual on-card adapt button for structured formats. * feat(backend): stronger visualize adapt prompts for better conversion The format-adapt prompts were terse and gave the model little guidance, so conversions were mediocre on a small model. Each format now has detailed, role-framed instructions (pick the fitting diagram type and quote labels safely; find a category + numeric measure and aggregate for charts; extract 2-8 KPIs for stat-cards; use only facts, no invention) plus temperature 0.2 and output trim. JSON renderers also strip a fenced block in case the model wraps the output. * feat(ai-studio): render markdown in table cells and stat-card values LLM-produced field values often contain markdown (bold, lists, links, inline code). A new RichText helper renders such strings as markdown in table cells and stat-card values, while leaving plain strings (e.g. 'user_id') untouched so they are not mangled. Raw HTML stays escaped (no rehype-raw) to avoid XSS from untrusted model output. * fix(ai-studio): execution-panel dark-mode contrast, modal heading, visualize label - Set explicit text color (theme token) on the Execution Log and node-detail panels so their text stays readable in dark mode instead of inheriting a dark color. - Align the welcome modal's icon and title on one horizontal row. - Shorten the Visualize node's palette description so its subtitle fits. * feat(ai-studio): linked CDN logo with theme-aware variants Hide the SDK app-bar logo (no prop to replace or link it yet) and render the Workflow Builder CDN logo over the slot, linked to workflowbuilder.io. Swap the transparent dark-text / white-text variants by the SDK theme so the logo reads on both light and dark app bars (the -solid asset bakes in a white background). Remove once the SDK exposes a logo/logoHref prop. * fix(ai-studio): keep app-bar Save button clear of the logo overlay display:none on the SDK logo collapsed its flex box, sliding the Save button left under the fixed logo overlay. Hide the logo with visibility and reserve its width instead so the nav-segment keeps its place. * feat(ai-studio): add Visualize node to AI Debate template End the debate flow in a Visualize node and shape the verdict as markdown (verdict line + For/Against table + reasoning) so it renders richly. * feat(ai-studio): add Visualize node to Meeting Notes template End the flow in a Visualize node and shape the recap as markdown with an action-items table (owner/task/due) so it renders as a structured artifact. * feat(ai-studio): converge Content Repurposer into a Visualize node Add a Content Pack agent that merges the three channel drafts into one sectioned markdown document, then a Visualize node to render it. Keeps the fan-out and gives the flow a single final artifact. * refactor(ai-studio): drop dead code and unused exports Remove the unused isTurnstileEnabled helper and de-export symbols that are only used within their own module (renderers, VISUALIZE_MODES/VisualizeMode, DetectResult, VisualizeNode). knip-clean across ai-studio, backend, worker. * chore(ai-studio): drop internal planning docs from the PR Remove the long-work backlog and crystallize plan; agent-process scratch, not product docs for the public repo. Net diff vs main no longer includes them. * fix(ai-studio): declare ai-agent output so node mentions resolve The ai-agent palette item had no outputSchema, so {{ nodes..response }} references (e.g. in decision conditions) fell through to the SDK's unresolved 'missing mention' label. Declaring the response output renders a clean 'Classify Ticket · Response' pill instead. * feat(ai-studio): web search tool for AI Agent nodes (Tavily) Add an opt-in 'Web search' toggle to the AI Agent node. When enabled and TAVILY_API_KEY is set, the executor gives the agent a Tavily-backed web_search tool via the AI SDK tool-calling loop (capped at a few steps). The loop runs inside the activity, so it needs no graph cycles and stays DAG-compatible. Without the key the node runs unchanged (tool simply not exposed). * feat(ai-studio): add Market Research template using web search A Trigger -> Research Agent (web search enabled) -> Visualize flow that showcases the AI Agent web-search tool: the agent searches and writes a sourced markdown brief, rendered in the Visualize node. Needs TAVILY_API_KEY to actually search; runs without it, just answers from the model. * feat(ai-studio): always AI-adapt visualize output, drop the toggle Remove the aiAdapt switch from the Visualize node. Structured formats (chart/diagram/table/json/stat-cards) are now always LLM-adapted to fit the selected format; markdown/text stay passthrough. The manual adapt button is gone too since adaptation is automatic. * refactor(ai-studio): remove the Error Policy feature entirely Drop the per-node Error Policy control (schema/uischema/defaults across all nodes + errorPolicy from every template) and the now-dead ErrorHandle decorator/component. Nodes rely on the engine default (fail on error). Simplifies the demo UI - the feature was underdeveloped and not worth keeping. * feat(ai-studio): default the demo to English The SDK i18n auto-detects the browser locale, so the public demo came up in Polish on PL machines. An inline script in index.html pins 'en' before the module (and the SDK i18n it loads) boots, unless the visitor made a clean en/pl choice via the selector. Placed in HTML because a bundler import can't be guaranteed to run before the SDK's i18n init. Pairs with the SDK selector fix (regional locales) tracked separately. * refactor(ai-studio): trim narration comments to a self-explanatory minimum Remove comments that restate code or justify changes across ai-studio, the execution worker and the backend; keep only terse notes for genuine traps and non-obvious constraints (single-instance rate limit, fail-closed verification, mermaid pre-validation, script ordering, browser quirks). Rationale lives in commit messages, not inline. No code changes. * feat(ai-studio): tidy node + form UI via SDK CSS overrides Widen decision-branch spacing, hide the decision node's unused outer output port (branches carry their own), and give prompt inputs a monospace font and a capped height. Consumer-side overrides on SDK tokens/classes, no SDK change. * feat(ai-studio): add undo/redo Port the undo-redo plugin from demo (app-bar buttons + Ctrl+Z / Ctrl+Shift+Z), register it on WorkflowBuilder.Root, and add its react-i18next dependency. * fix(ai-studio): make execution node highlight solid across all statuses Running/completed/failed rings mixed opacities (40/90/50%), so some looked half-transparent and others solid. Drop the transparency so every status ring is a consistent solid color. * feat(ai-studio): center execution log, drop the node-detail popup Move the execution log to the bottom center. Clicking a node's flag marker now reveals and flashes that node's entry in the log instead of opening a separate detail panel (that panel is removed). * fix(ai-studio): flag opens the log; node selection highlights its entry The flag marker now explicitly opens the log (store-owned collapsed state) so it works even when the log is already showing that node. Selecting a node on the canvas highlights its log entry without opening the log. Drops the select-driven auto-expand effect that could fight a manual collapse. * fix(ai-studio): center the icon inside node execution markers * refactor(backend): drop the in-house execute rate limit Rate limiting now comes solely from the shared execute rate-limit middleware (per-minute/per-day, proxy-aware, tested), which also covers the Visualize adapt endpoint via a shared instance so both LLM entry points draw from one budget. guardExecution keeps only the Turnstile check. * fix(ai-studio): drop the English pin, let the locale reflect reality With the SDK selector fix the displayed language matches what actually renders, so starting in the browser's language is correct behavior. * refactor(ai-studio): derive log highlight from canvas selection Clicking a node (its flag marker included) already selects it on the canvas, so the execution store's selectedNodeId, its mirror effect and the extra store write were redundant. The log derives the highlight from the SDK's single-selection hook - it now also clears on deselect - and the flag click only expands the log. * fix(ai-studio): cancel stale visualize adaptations, drop the chart chip Move the AI-adapt call into an effect with cleanup so an in-flight response can't overwrite a newer output (and no LLM call is wasted on it). Remove the 'Try as chart' chip and the chartable detection it hung on - always-on adapt covers it and the numbers-array path rendered as plain text anyway. VALID_MODES now derives from the schema's VISUALIZE_MODES instead of a hand copy. * fix(ai-studio): one undo per keypress, keep native text-field undo The SDK useKeyPress toggles on OS key-repeat, so holding Ctrl+Z undid many steps, and skipTarget hijacked Ctrl+Z inside text fields. Replace with a plain keydown listener guarded by event.repeat and text-target checks; adds Ctrl+Shift+Z as redo. * refactor(ai-studio): bundle the brand logo locally instead of the CDN * refactor(ai-studio): trim template noise and close latent races Drop redundant errors:[] from template data (the canonical demo templates omit it), serialize Turnstile token requests (the single widget can't handle overlapping calls), use useId for mermaid render ids (the module counter could collide across concurrent diagrams), and shorten the textarea font stack to ui-monospace/monospace. * docs(ai-studio): branch-gap override is app styling, not a pending SDK fix The SDK gap applies correctly but scales to about 2px at the demo zoom-to-fit, reading as no spacing; widening it is this app's presentation choice, so the SDK default stays untouched and the change was dropped from the SDK polish PR. * refactor(ai-studio): drop overrides superseded by SDK fixes The decision node's dead outer port and the branch-card panel spacing are fixed in the SDK, and prompt inputs cap their growth via the new maxRows uischema option instead of a CSS max-height. * docs(ai-studio): drop a narration comment from the app bootstrap * refactor(ai-studio): drop the branch-gap override The spacing complaint was about the properties panel, fixed in the SDK; the canvas override was a misdiagnosis artifact. * refactor(ai-studio): pass the brand logo through the SDK props The logo/logoHref props landed in the SDK, so the fixed-position overlay, the hash-prefix CSS hide and the theme-swap component all go away. --------- Co-authored-by: Jan Librowski --- apps/ai-studio/.env.example | 6 + apps/ai-studio/package.json | 6 + apps/ai-studio/src/app/app.tsx | 20 +- apps/ai-studio/src/app/node-overrides.css | 3 + .../assets/workflow-builder-logo-white.svg | 18 + .../src/assets/workflow-builder-logo.svg | 18 + .../disclaimer/disclaimer-modal.module.css | 111 +++ .../disclaimer/disclaimer-modal.tsx | 75 ++ .../error-handle/error-handle.module.css | 3 - .../components/error-handle/error-handle.tsx | 39 - .../src/components/execution/highlighting.css | 6 +- .../components/execution/log-panel.module.css | 12 +- .../src/components/execution/log-panel.tsx | 25 +- .../execution/node-detail.module.css | 91 -- .../src/components/execution/node-detail.tsx | 34 - .../execution/node-markers.module.css | 3 + .../src/components/execution/node-markers.tsx | 5 +- .../components/visualize/chart-renderer.tsx | 174 ++++ .../components/visualize/diagram-renderer.tsx | 63 ++ .../components/visualize/renderers.module.css | 234 +++++ .../src/components/visualize/renderers.tsx | 264 ++++++ .../visualize/visualize-card.module.css | 157 ++++ .../components/visualize/visualize-card.tsx | 174 ++++ .../visualize/visualize-modal.module.css | 92 ++ .../components/visualize/visualize-modal.tsx | 80 ++ apps/ai-studio/src/config.ts | 3 + apps/ai-studio/src/data/ai-debate-flow.ts | 172 ++++ .../ai-studio/src/data/ai-studio-templates.ts | 14 +- .../src/data/content-repurposer-flow.ts | 223 +++++ apps/ai-studio/src/data/meeting-notes-flow.ts | 165 ++++ apps/ai-studio/src/data/node-types.ts | 3 +- apps/ai-studio/src/data/research-flow.ts | 105 +++ apps/ai-studio/src/data/sales-inquiry-flow.ts | 339 ------- .../ai-studio/src/data/support-triage-flow.ts | 336 +++++++ .../src/hooks/use-backend-execution.ts | 8 +- .../nodes/ai-agent/default-properties-data.ts | 2 +- apps/ai-studio/src/nodes/ai-agent/index.ts | 7 + apps/ai-studio/src/nodes/ai-agent/schema.ts | 6 +- apps/ai-studio/src/nodes/ai-agent/uischema.ts | 7 +- .../nodes/decision/default-properties-data.ts | 1 - apps/ai-studio/src/nodes/decision/schema.ts | 3 +- apps/ai-studio/src/nodes/decision/uischema.ts | 5 - .../nodes/trigger/default-properties-data.ts | 1 - apps/ai-studio/src/nodes/trigger/schema.ts | 3 +- apps/ai-studio/src/nodes/trigger/uischema.ts | 6 +- .../visualize/default-properties-data.ts | 9 + apps/ai-studio/src/nodes/visualize/index.ts | 16 + apps/ai-studio/src/nodes/visualize/schema.ts | 29 + .../ai-studio/src/nodes/visualize/uischema.ts | 23 + apps/ai-studio/src/plugin.ts | 4 +- .../ai-studio/src/plugins/undo-redo/README.md | 9 + .../buttons-undo-redo/buttons-undo-redo.tsx | 23 + .../plugins/undo-redo/functions/decorators.ts | 36 + .../hooks/use-undo-redo-keyboard-handler.tsx | 37 + .../undo-redo/locales/en/translation.json | 8 + .../undo-redo/locales/pl/translation.json | 8 + .../src/plugins/undo-redo/plugin-exports.ts | 32 + .../providers/undo-redo-provider.tsx | 15 + .../undo-redo/stores/use-undo-redo-store.ts | 161 ++++ apps/ai-studio/src/security/turnstile.ts | 94 ++ .../src/stores/use-execution-store.ts | 12 +- .../src/utils/adapt-visualization.ts | 20 + .../ai-studio/src/utils/detect-format.test.ts | 72 ++ apps/ai-studio/src/utils/detect-format.ts | 127 +++ .../src/utils/export-visualization.ts | 55 ++ apps/backend/.env.example | 9 + apps/backend/package.json | 2 + apps/backend/src/env.ts | 5 + apps/backend/src/routes/visualize.ts | 80 ++ apps/backend/src/routes/workflows.ts | 7 + apps/backend/src/security/execution-guard.ts | 38 + apps/backend/src/security/turnstile.ts | 43 + apps/backend/src/server.ts | 18 +- apps/execution-worker/.env.example | 7 +- .../src/activities/ai-agent.ts | 16 +- .../src/domain/ai-studio-nodes.ts | 12 +- .../src/engines/temporal/worker.ts | 5 +- apps/execution-worker/src/env.ts | 5 +- .../src/executors/visualize.ts | 4 + apps/execution-worker/src/tools/web-search.ts | 55 ++ pnpm-lock.yaml | 843 +++++++++++++++++- 81 files changed, 4479 insertions(+), 582 deletions(-) create mode 100644 apps/ai-studio/.env.example create mode 100644 apps/ai-studio/src/app/node-overrides.css create mode 100644 apps/ai-studio/src/assets/workflow-builder-logo-white.svg create mode 100644 apps/ai-studio/src/assets/workflow-builder-logo.svg create mode 100644 apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css create mode 100644 apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx delete mode 100644 apps/ai-studio/src/components/error-handle/error-handle.module.css delete mode 100644 apps/ai-studio/src/components/error-handle/error-handle.tsx delete mode 100644 apps/ai-studio/src/components/execution/node-detail.module.css delete mode 100644 apps/ai-studio/src/components/execution/node-detail.tsx create mode 100644 apps/ai-studio/src/components/visualize/chart-renderer.tsx create mode 100644 apps/ai-studio/src/components/visualize/diagram-renderer.tsx create mode 100644 apps/ai-studio/src/components/visualize/renderers.module.css create mode 100644 apps/ai-studio/src/components/visualize/renderers.tsx create mode 100644 apps/ai-studio/src/components/visualize/visualize-card.module.css create mode 100644 apps/ai-studio/src/components/visualize/visualize-card.tsx create mode 100644 apps/ai-studio/src/components/visualize/visualize-modal.module.css create mode 100644 apps/ai-studio/src/components/visualize/visualize-modal.tsx create mode 100644 apps/ai-studio/src/data/ai-debate-flow.ts create mode 100644 apps/ai-studio/src/data/content-repurposer-flow.ts create mode 100644 apps/ai-studio/src/data/meeting-notes-flow.ts create mode 100644 apps/ai-studio/src/data/research-flow.ts delete mode 100644 apps/ai-studio/src/data/sales-inquiry-flow.ts create mode 100644 apps/ai-studio/src/data/support-triage-flow.ts create mode 100644 apps/ai-studio/src/nodes/visualize/default-properties-data.ts create mode 100644 apps/ai-studio/src/nodes/visualize/index.ts create mode 100644 apps/ai-studio/src/nodes/visualize/schema.ts create mode 100644 apps/ai-studio/src/nodes/visualize/uischema.ts create mode 100644 apps/ai-studio/src/plugins/undo-redo/README.md create mode 100644 apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx create mode 100644 apps/ai-studio/src/plugins/undo-redo/functions/decorators.ts create mode 100644 apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx create mode 100644 apps/ai-studio/src/plugins/undo-redo/locales/en/translation.json create mode 100644 apps/ai-studio/src/plugins/undo-redo/locales/pl/translation.json create mode 100644 apps/ai-studio/src/plugins/undo-redo/plugin-exports.ts create mode 100644 apps/ai-studio/src/plugins/undo-redo/providers/undo-redo-provider.tsx create mode 100644 apps/ai-studio/src/plugins/undo-redo/stores/use-undo-redo-store.ts create mode 100644 apps/ai-studio/src/security/turnstile.ts create mode 100644 apps/ai-studio/src/utils/adapt-visualization.ts create mode 100644 apps/ai-studio/src/utils/detect-format.test.ts create mode 100644 apps/ai-studio/src/utils/detect-format.ts create mode 100644 apps/ai-studio/src/utils/export-visualization.ts create mode 100644 apps/backend/src/routes/visualize.ts create mode 100644 apps/backend/src/security/execution-guard.ts create mode 100644 apps/backend/src/security/turnstile.ts create mode 100644 apps/execution-worker/src/executors/visualize.ts create mode 100644 apps/execution-worker/src/tools/web-search.ts 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..243eec574 100644 --- a/apps/ai-studio/package.json +++ b/apps/ai-studio/package.json @@ -20,9 +20,15 @@ "@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-i18next": "^15.4.1", + "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..7b21ee11f 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -1,28 +1,40 @@ import { WorkflowBuilder } from '@workflowbuilder/sdk'; +import './node-overrides.css'; import '@workflowbuilder/sdk/style.css'; +import logoDark from '../assets/workflow-builder-logo-white.svg'; +import logoLight from '../assets/workflow-builder-logo.svg'; 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'; +import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports'; + +const flagship = supportTriageFlow.value; export function App() { return ( - + ); } diff --git a/apps/ai-studio/src/app/node-overrides.css b/apps/ai-studio/src/app/node-overrides.css new file mode 100644 index 000000000..9bcfc5e91 --- /dev/null +++ b/apps/ai-studio/src/app/node-overrides.css @@ -0,0 +1,3 @@ +[class^='_json-form-container_'] textarea { + font-family: ui-monospace, monospace; +} diff --git a/apps/ai-studio/src/assets/workflow-builder-logo-white.svg b/apps/ai-studio/src/assets/workflow-builder-logo-white.svg new file mode 100644 index 000000000..4d1b9e0c3 --- /dev/null +++ b/apps/ai-studio/src/assets/workflow-builder-logo-white.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/ai-studio/src/assets/workflow-builder-logo.svg b/apps/ai-studio/src/assets/workflow-builder-logo.svg new file mode 100644 index 000000000..33558f644 --- /dev/null +++ b/apps/ai-studio/src/assets/workflow-builder-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + 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..38d83d966 --- /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 { + // storage unavailable + } + 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/error-handle/error-handle.module.css b/apps/ai-studio/src/components/error-handle/error-handle.module.css deleted file mode 100644 index 292525a0b..000000000 --- a/apps/ai-studio/src/components/error-handle/error-handle.module.css +++ /dev/null @@ -1,3 +0,0 @@ -:global(.react-flow__handle).error-handle { - border: 2px solid var(--ai-studio-status-color--failed); -} diff --git a/apps/ai-studio/src/components/error-handle/error-handle.tsx b/apps/ai-studio/src/components/error-handle/error-handle.tsx deleted file mode 100644 index 74588dce4..000000000 --- a/apps/ai-studio/src/components/error-handle/error-handle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { getStoreEdges, setStoreEdges, useStore } from '@workflowbuilder/sdk'; -import { Handle, Position, useUpdateNodeInternals } from '@xyflow/react'; -import { useEffect } from 'react'; - -import styles from './error-handle.module.css'; - -type Props = { - props?: { - nodeId: string; - }; -}; - -// Source handle (id 'errorRoute') that materialises the runner's -// `errorPolicy: 'errorRoute'` contract on the canvas — edges with -// `sourceHandle === 'errorRoute'` only fire when the upstream node fails -// with that policy. The handle is therefore rendered only for nodes -// that opted into routing; 'fail'/'continue' nodes have no use for it. -export function ErrorHandle({ props }: Props) { - const nodeId = props?.nodeId ?? ''; - const errorPolicy = useStore((s) => s.nodes.find((node) => node.id === nodeId)?.data.properties?.errorPolicy); - const updateNodeInternals = useUpdateNodeInternals(); - - useEffect(() => { - if (nodeId) updateNodeInternals(nodeId); - }, [errorPolicy, nodeId, updateNodeInternals]); - - useEffect(() => { - if (!nodeId || errorPolicy === 'errorRoute') return; - const edges = getStoreEdges(); - const remaining = edges.filter((edge) => !(edge.source === nodeId && edge.sourceHandle === 'errorRoute')); - if (remaining.length !== edges.length) { - setStoreEdges(remaining); - } - }, [errorPolicy, nodeId]); - - if (errorPolicy !== 'errorRoute') return null; - - return ; -} diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css index 8e1cf154b..fef4548f1 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -13,9 +13,9 @@ html[data-theme='dark'] { --ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%); --ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%); - --ai-studio-node-shadow-color--active: color-mix(in srgb, var(--ai-studio-edge-color--active), transparent 60%); - --ai-studio-node-shadow-color--completed: color-mix(in srgb, var(--ai-studio-edge-color--active), transparent 10%); - --ai-studio-node-shadow-color--failed: color-mix(in srgb, var(--ax-colors-red-400), transparent 50%); + --ai-studio-node-shadow-color--active: var(--ai-studio-edge-color--active); + --ai-studio-node-shadow-color--completed: var(--ai-studio-edge-color--active); + --ai-studio-node-shadow-color--failed: var(--ax-colors-red-400); --ai-studio-node-shadow--active: var(--ax-token-shadow-focus-node-active-x) var(--ax-token-shadow-focus-node-active-y) var(--ax-token-shadow-focus-node-active-blur) var(--ax-token-shadow-focus-node-active-spread) 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..20627114d 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -1,12 +1,15 @@ .panel { position: fixed; bottom: 1.5rem; - right: 1.5rem; - width: 26rem; + left: 50%; + transform: translateX(-50%); + width: 30rem; + max-width: calc(100vw - 3rem); max-height: 60vh; 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; @@ -157,3 +160,8 @@ .status--cancelled { opacity: 0.5; } + +.event--highlighted { + background: color-mix(in srgb, var(--ax-colors-acc1-500, #1096e7), transparent 88%); + box-shadow: inset 0.1875rem 0 0 var(--ax-colors-acc1-500, #1096e7); +} diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index a696beff2..f3781c840 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -1,21 +1,23 @@ +import { useSingleSelectedElement } from '@workflowbuilder/sdk'; import { useEffect, useRef, useState } from 'react'; import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; import styles from './log-panel.module.css'; -import { useExecutionStore } from '../../stores/use-execution-store'; +import { toggleLog, useExecutionStore } from '../../stores/use-execution-store'; import { extractOutputText } from '../../utils/extract-output-text'; function formatTime(iso: string) { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } -function EventRow({ event }: { event: ExecutionEvent }) { +function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNodeId: string | null }) { const [expanded, setExpanded] = useState(false); const nodeId = (event as { nodeId?: string | null }).nodeId; const isNode = typeof nodeId === 'string' && nodeId.length > 0; + const highlighted = isNode && nodeId === selectedNodeId; const label = event.type.replaceAll('_', ' '); let detail: string | undefined; @@ -42,7 +44,10 @@ function EventRow({ event }: { event: ExecutionEvent }) { const truncated = detail && detail.length > 120 ? detail.slice(0, 120) + '…' : detail; return ( -
+
hasDetail && setExpanded((v) => !v)}> {label} {isNode && {(event as { nodeId: string }).nodeId.slice(0, 8)}} @@ -61,7 +66,10 @@ function EventRow({ event }: { event: ExecutionEvent }) { export function ExecutionLogPanel() { const events = useExecutionStore((s) => s.events); const status = useExecutionStore((s) => s.status); - const [collapsed, setCollapsed] = useState(false); + const collapsed = useExecutionStore((s) => s.logCollapsed); + // Clicking a node (incl. its flag marker) selects it on the canvas; the + // highlight derives from that selection, so it clears on deselect. + const selectedNodeId = useSingleSelectedElement()?.node?.id ?? null; const bodyRef = useRef(null); @@ -71,11 +79,16 @@ export function ExecutionLogPanel() { } }, [events.length, collapsed]); + useEffect(() => { + if (!selectedNodeId || collapsed) return; + bodyRef.current?.querySelector(`[data-node-id="${selectedNodeId}"]`)?.scrollIntoView({ block: 'nearest' }); + }, [selectedNodeId, collapsed]); + if (events.length === 0 && status === 'idle') return null; return (
-
setCollapsed((v) => !v)}> +
Execution Log {status} {collapsed ? '▲' : '▼'} @@ -83,7 +96,7 @@ export function ExecutionLogPanel() { {!collapsed && (
{events.map((event) => ( - + ))}
)} diff --git a/apps/ai-studio/src/components/execution/node-detail.module.css b/apps/ai-studio/src/components/execution/node-detail.module.css deleted file mode 100644 index 7d869c47c..000000000 --- a/apps/ai-studio/src/components/execution/node-detail.module.css +++ /dev/null @@ -1,91 +0,0 @@ -.overlay { - position: fixed; - bottom: 1.5rem; - left: 1.5rem; - width: 28rem; - max-height: 60vh; - background: var(--wb-app-bar-background); - border: 0.0625rem solid var(--wb-app-bar-border-color); - border-radius: var(--wb-app-bar-border-radius); - display: flex; - flex-direction: column; - z-index: 11; - font-size: 0.75rem; - overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); -} - -.header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-bottom: 0.0625rem solid var(--wb-app-bar-border-color); - flex-shrink: 0; -} - -.header-title { - font-weight: 600; - font-size: 0.75rem; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status { - font-size: 0.65rem; - font-weight: 500; - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; -} - -.status--completed { - color: var(--ai-studio-status-color--completed); - background: var(--ai-studio-status-bg--completed); -} - -.status--failed { - color: var(--ai-studio-status-color--failed); - background: var(--ai-studio-status-bg--failed); -} - -.close-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - background: none; - color: var(--ax-txt-secondary-default); - cursor: pointer; - border-radius: 0.25rem; - font-size: 0.85rem; - line-height: 1; - flex-shrink: 0; - - &:hover { - background: color-mix(in srgb, var(--wb-app-bar-border-color), transparent 30%); - } - - svg { - width: 100%; - height: 100%; - } -} - -.body { - overflow-y: auto; - flex: 1; - padding: 0.5rem 0.75rem; -} - -.output { - white-space: pre-wrap; - word-break: break-word; - line-height: 1.5; - font-size: 0.85rem; - font-family: inherit; - color: var(--ax-txt-primary-default); -} diff --git a/apps/ai-studio/src/components/execution/node-detail.tsx b/apps/ai-studio/src/components/execution/node-detail.tsx deleted file mode 100644 index 0afd49285..000000000 --- a/apps/ai-studio/src/components/execution/node-detail.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { X } from '@phosphor-icons/react'; - -import styles from './node-detail.module.css'; - -import { selectNode, useExecutionStore } from '../../stores/use-execution-store'; -import { extractOutputText } from '../../utils/extract-output-text'; - -export function ExecutionNodeDetail() { - const selectedNodeId = useExecutionStore((s) => s.selectedNodeId); - const nodeState = useExecutionStore((s) => (s.selectedNodeId ? s.nodeStates[s.selectedNodeId] : undefined)); - - if (!selectedNodeId || !nodeState) return null; - if (nodeState.status !== 'completed' && nodeState.status !== 'failed') return null; - - const outputText = - nodeState.status === 'completed' - ? extractOutputText(nodeState.output) || '(no output)' - : (nodeState.error?.message ?? '(no error message)'); - - return ( -
-
- {selectedNodeId} - {nodeState.status} - -
-
-
{outputText}
-
-
- ); -} diff --git a/apps/ai-studio/src/components/execution/node-markers.module.css b/apps/ai-studio/src/components/execution/node-markers.module.css index 9e52d6fae..158f88ef2 100644 --- a/apps/ai-studio/src/components/execution/node-markers.module.css +++ b/apps/ai-studio/src/components/execution/node-markers.module.css @@ -10,6 +10,7 @@ color: var(--ax-public-node-title-subtitle); display: flex; align-items: center; + justify-content: center; gap: 0.5rem; &, @@ -20,6 +21,8 @@ .icon { display: inline-flex; + align-items: center; + justify-content: center; width: 1.25rem; height: 1.25rem; flex-shrink: 0; diff --git a/apps/ai-studio/src/components/execution/node-markers.tsx b/apps/ai-studio/src/components/execution/node-markers.tsx index a8b3bb686..d5a130ce4 100644 --- a/apps/ai-studio/src/components/execution/node-markers.tsx +++ b/apps/ai-studio/src/components/execution/node-markers.tsx @@ -3,7 +3,7 @@ import { Icon } from '@workflowbuilder/sdk'; import styles from './node-markers.module.css'; -import { selectNode, useExecutionStore } from '../../stores/use-execution-store'; +import { setLogCollapsed, useExecutionStore } from '../../stores/use-execution-store'; type Props = { props?: { @@ -19,10 +19,11 @@ export function ExecutionNodeMarkers({ props }: Props) { const isClickable = nodeState.status === 'completed' || nodeState.status === 'failed'; + // The click also selects the node on the canvas, which drives the log highlight. return (
selectNode(nodeId) : undefined} + onClick={isClickable ? () => setLogCollapsed(false) : undefined} > {nodeState.status === 'running' && ( 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..58e11a5fe --- /dev/null +++ b/apps/ai-studio/src/components/visualize/chart-renderer.tsx @@ -0,0 +1,174 @@ +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; + +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + // not raw JSON + } + const fence = /```(?:json)?\s*\n([\s\S]*?)```/.exec(text); + if (fence) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // not JSON + } + } + 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..fc19d6aa5 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/diagram-renderer.tsx @@ -0,0 +1,63 @@ +import mermaid from 'mermaid'; +import { useEffect, useId, useState } from 'react'; + +import styles from './renderers.module.css'; + +import type { RendererProps } from './renderers'; + +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' }); + +// Validate before render: invalid input falls back to raw text so mermaid never injects its "Syntax error" graphic. +export function DiagramRenderer({ text, data }: RendererProps) { + const raw = typeof data === 'string' ? data : text; + const fence = /```mermaid\s*\n([\s\S]*?)```/.exec(raw); + const source = fence ? fence[1].trim() : raw; + const renderId = useId().replaceAll(':', ''); + const [svg, setSvg] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + 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-${renderId}`, 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..6d64314e0 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/renderers.module.css @@ -0,0 +1,234 @@ +.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); +} + +.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 { + 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 { + 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; +} + +.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 { + width: 100%; + height: 14rem; +} + +.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..db03c4f05 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/renderers.tsx @@ -0,0 +1,264 @@ +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'; + +const ChartRenderer = lazy(() => import('./chart-renderer').then((module) => ({ default: module.ChartRenderer }))); +const DiagramRenderer = lazy(() => + import('./diagram-renderer').then((module) => ({ default: module.DiagramRenderer })), +); + +export type RendererProps = { + text: string; + 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 JSON + } + const fence = /```(?:json)?\s*\n?([\s\S]*?)```/.exec(text); + if (fence) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // not JSON + } + } + 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()); +} + +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) + ); +} + +// Untrusted model output: render markdown but never raw HTML (Markdown escapes it). +function RichText({ value }: { value: string }) { + if (!hasRichText(value)) { + return <>{value}; + } + return ( +
+ {value} +
+ ); +} + +// Unwrap
 so embedded ```mermaid / ```json blocks render as real components.
+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)}
+
+ ))} +
+ ); +} + +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..a89c57d1c --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-card.module.css @@ -0,0 +1,157 @@ +.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); +} + +.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..13944b2f0 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -0,0 +1,174 @@ +import { ArrowsOut, ClipboardText, Copy, DownloadSimple, Eye } 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 { VISUALIZE_MODES } from '../../nodes/visualize/schema'; +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(VISUALIZE_MODES); +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.

+
+ ); +} + +export function VisualizeCard({ props }: Props) { + const nodeId = props?.nodeId ?? ''; + const [expanded, setExpanded] = useState(false); + const [adaptedText, setAdaptedText] = useState(null); + const [adapting, setAdapting] = useState(false); + const contentRef = useRef(null); + + // Nodes/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 } | undefined; + const mode: VisualizeMode = + properties?.mode && VALID_MODES.has(properties.mode) ? (properties.mode as VisualizeMode) : 'auto'; + const detection = detectFormat(text); + const activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode; + + useEffect(() => { + setAdaptedText(null); + }, [text]); + + useEffect(() => { + if (!hasOutput || !ADAPTABLE.has(activeRenderer) || adaptedText !== null) return; + let cancelled = false; + setAdapting(true); + adaptVisualization(text, activeRenderer) + .then((output) => { + if (!cancelled) setAdaptedText(output); + }) + .catch(() => { + // keep original content + }) + .finally(() => { + if (!cancelled) setAdapting(false); + }); + return () => { + cancelled = true; + }; + }, [hasOutput, 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 isVector = activeRenderer === 'chart' || activeRenderer === 'diagram'; + + return ( +
+ {hasOutput && Renderer ? ( + <> +
+ {badge} +
+ + + + +
+
+
+ {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..775dc8717 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-modal.tsx @@ -0,0 +1,80 @@ +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; +}; + +// Portaled to document.body so the 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..03d6fff01 100644 --- a/apps/ai-studio/src/config.ts +++ b/apps/ai-studio/src/config.ts @@ -1 +1,4 @@ export const BACKEND_URL = import.meta.env['VITE_BACKEND_URL'] ?? 'http://127.0.0.1:3001'; + +// Public site key. Undefined = bot protection disabled (local dev). +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..d469b66e3 --- /dev/null +++ b/apps/ai-studio/src/data/ai-debate-flow.ts @@ -0,0 +1,172 @@ +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?', + }, + 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.`, + }, + 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.`, + }, + 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.`, + }, + 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).', + }, + 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..df469fc47 --- /dev/null +++ b/apps/ai-studio/src/data/content-repurposer-flow.ts @@ -0,0 +1,223 @@ +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.`, + }, + 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/, ...).`, + }, + 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.`, + }, + 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`, + }, + 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.`, + }, + 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).', + }, + 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..7189a5498 --- /dev/null +++ b/apps/ai-studio/src/data/meeting-notes-flow.ts @@ -0,0 +1,165 @@ +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.`, + }, + 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.`, + }, + 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.`, + }, + 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_".`, + }, + 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).', + }, + 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..444006d97 --- /dev/null +++ b/apps/ai-studio/src/data/research-flow.ts @@ -0,0 +1,105 @@ +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.`, + }, + 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, + }, + 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).', + }, + 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..7fa6b06a6 --- /dev/null +++ b/apps/ai-studio/src/data/support-triage-flow.ts @@ -0,0 +1,336 @@ +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`, + }, + 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.`, + }, + 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: [], + }, + ], + }, + 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".`, + }, + 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".`, + }, + 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".`, + }, + 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.`, + }, + 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).', + }, + 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..8c5fa6b16 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,14 @@ export function useBackendExecution() { const { id: workflowId } = (await wfResponse.json()) as { id: string }; + 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..b4483d606 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,5 @@ export const defaultPropertiesData: NodeDataProperties = { label: 'AI Agent', description: '', systemPrompt: '', - errorPolicy: 'fail', + webSearch: false, }; diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts index 0dffa4dfe..d74e375c6 100644 --- a/apps/ai-studio/src/nodes/ai-agent/index.ts +++ b/apps/ai-studio/src/nodes/ai-agent/index.ts @@ -13,4 +13,11 @@ export const aiAgentPaletteItem: PaletteItem = { defaultPropertiesData, schema, uischema, + // Lets `{{ nodes..response }}` references resolve to a real mention instead of a "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..969af44b5 100644 --- a/apps/ai-studio/src/nodes/ai-agent/schema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/schema.ts @@ -1,14 +1,16 @@ -import { errorPolicyProperty, sharedProperties } from '@workflowbuilder/sdk'; +import { sharedProperties } from '@workflowbuilder/sdk'; import type { NodeSchema } from '@workflowbuilder/sdk'; export const schema = { type: 'object', properties: { ...sharedProperties, - ...errorPolicyProperty, 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..06abc4aa4 100644 --- a/apps/ai-studio/src/nodes/ai-agent/uischema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/uischema.ts @@ -20,11 +20,12 @@ export const uischema: UISchema = { label: 'System Prompt', placeholder: 'Describe what the AI should do...', minRows: 5, + maxRows: 14, }, { - type: 'Select', - scope: scope('properties.errorPolicy'), - label: 'Error Policy', + type: 'Switch', + scope: scope('properties.webSearch'), + label: 'Web search (let the agent look things up)', }, ], }; diff --git a/apps/ai-studio/src/nodes/decision/default-properties-data.ts b/apps/ai-studio/src/nodes/decision/default-properties-data.ts index 325a08e82..4a0ca0969 100644 --- a/apps/ai-studio/src/nodes/decision/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/decision/default-properties-data.ts @@ -6,5 +6,4 @@ export const defaultPropertiesData: NodeDataProperties = { label: 'Decision', description: '', decisionBranches: [], - errorPolicy: 'fail', }; diff --git a/apps/ai-studio/src/nodes/decision/schema.ts b/apps/ai-studio/src/nodes/decision/schema.ts index 3818559c0..6186d362f 100644 --- a/apps/ai-studio/src/nodes/decision/schema.ts +++ b/apps/ai-studio/src/nodes/decision/schema.ts @@ -1,4 +1,4 @@ -import { errorPolicyProperty, sharedProperties } from '@workflowbuilder/sdk'; +import { sharedProperties } from '@workflowbuilder/sdk'; import type { NodeSchema } from '@workflowbuilder/sdk'; const conditions = { @@ -31,7 +31,6 @@ export const schema = { type: 'object', properties: { ...sharedProperties, - ...errorPolicyProperty, decisionBranches, }, } satisfies NodeSchema; diff --git a/apps/ai-studio/src/nodes/decision/uischema.ts b/apps/ai-studio/src/nodes/decision/uischema.ts index fa11cd924..1f9d607db 100644 --- a/apps/ai-studio/src/nodes/decision/uischema.ts +++ b/apps/ai-studio/src/nodes/decision/uischema.ts @@ -18,10 +18,5 @@ export const uischema: UISchema = { type: 'DecisionBranches', scope: scope('properties.decisionBranches'), }, - { - type: 'Select', - scope: scope('properties.errorPolicy'), - label: 'Error Policy', - }, ], }; diff --git a/apps/ai-studio/src/nodes/trigger/default-properties-data.ts b/apps/ai-studio/src/nodes/trigger/default-properties-data.ts index 0ae4f01ac..1a5f91f60 100644 --- a/apps/ai-studio/src/nodes/trigger/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/trigger/default-properties-data.ts @@ -6,5 +6,4 @@ export const defaultPropertiesData: NodeDataProperties = { label: 'AI Studio Trigger', description: '', inputPrompt: '', - errorPolicy: 'fail', }; diff --git a/apps/ai-studio/src/nodes/trigger/schema.ts b/apps/ai-studio/src/nodes/trigger/schema.ts index 097333b79..417f43319 100644 --- a/apps/ai-studio/src/nodes/trigger/schema.ts +++ b/apps/ai-studio/src/nodes/trigger/schema.ts @@ -1,11 +1,10 @@ -import { errorPolicyProperty, sharedProperties } from '@workflowbuilder/sdk'; +import { sharedProperties } from '@workflowbuilder/sdk'; import type { NodeSchema } from '@workflowbuilder/sdk'; export const schema = { type: 'object', properties: { ...sharedProperties, - ...errorPolicyProperty, inputPrompt: { type: 'string', }, diff --git a/apps/ai-studio/src/nodes/trigger/uischema.ts b/apps/ai-studio/src/nodes/trigger/uischema.ts index 41352f58c..09f353511 100644 --- a/apps/ai-studio/src/nodes/trigger/uischema.ts +++ b/apps/ai-studio/src/nodes/trigger/uischema.ts @@ -20,11 +20,7 @@ export const uischema: UISchema = { label: 'Input', placeholder: 'Paste the input data here (e.g. email content)...', minRows: 5, - }, - { - type: 'Select', - scope: scope('properties.errorPolicy'), - label: 'Error Policy', + maxRows: 14, }, ], }; 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..ddb6ac5a9 --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/default-properties-data.ts @@ -0,0 +1,9 @@ +import type { NodeDataProperties } from '@workflowbuilder/sdk'; + +import type { VisualizeSchema } from './schema'; + +export const defaultPropertiesData: NodeDataProperties = { + label: 'Visualize', + description: '', + mode: 'auto', +}; 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..04c96fcb4 --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/schema.ts @@ -0,0 +1,29 @@ +import { sharedProperties } from '@workflowbuilder/sdk'; +import type { NodeSchema } from '@workflowbuilder/sdk'; + +export 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, + mode: { + type: 'string', + options: VISUALIZE_MODES.map((value) => ({ label: MODE_LABELS[value], value })), + }, + }, +} 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..b4dc491c3 --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/uischema.ts @@ -0,0 +1,23 @@ +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', + }, + ], +}; diff --git a/apps/ai-studio/src/plugin.ts b/apps/ai-studio/src/plugin.ts index d4fb8698e..1fc5c8c6c 100644 --- a/apps/ai-studio/src/plugin.ts +++ b/apps/ai-studio/src/plugin.ts @@ -1,7 +1,7 @@ import { type OptionalNodeContent, registerComponentDecorator } from '@workflowbuilder/sdk'; -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; @@ -10,7 +10,7 @@ export function plugin(): void { content: ExecutionNodeMarkers, }); registerComponentDecorator('OptionalNodeContent', { - content: ErrorHandle, + content: VisualizeCard, place: 'after', }); } diff --git a/apps/ai-studio/src/plugins/undo-redo/README.md b/apps/ai-studio/src/plugins/undo-redo/README.md new file mode 100644 index 000000000..458a03907 --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/README.md @@ -0,0 +1,9 @@ +# Undo redo from Overflow Component Library + +Enable local session history for your **React Flow** graph. + +https://www.overflow.dev/premium?path=/docs/interaction-undo-redo-documentation--docs + +## About overflow.dev + +Designed for developers, [overflow.dev](https://www.overflow.dev/) provides a React Flow UI library to build visual workflows, automations, and tools with production-grade components. diff --git a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx new file mode 100644 index 000000000..c12f452fc --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -0,0 +1,23 @@ +import { NavButton } from '@synergycodes/overflow-ui'; +import { Icon, useStore } from '@workflowbuilder/sdk'; +import { useTranslation } from 'react-i18next'; + +import { redo, undo, useUndoRedoStore } from '../../stores/use-undo-redo-store'; + +export function ButtonsUndoRedo() { + const { t } = useTranslation(); + const canUndo = useUndoRedoStore((store) => store.past.length > 0); + const canRedo = useUndoRedoStore((store) => store.future.length > 0); + const isReadOnlyMode = useStore((store) => store.isReadOnlyMode); + + return ( + <> + + + + + + + + ); +} diff --git a/apps/ai-studio/src/plugins/undo-redo/functions/decorators.ts b/apps/ai-studio/src/plugins/undo-redo/functions/decorators.ts new file mode 100644 index 000000000..5b8b9304d --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/functions/decorators.ts @@ -0,0 +1,36 @@ +import { + processSnapshotWatching, + startSnapshotWatching, + stopSnapshotWatching, + takeSnapshot, +} from '../stores/use-undo-redo-store'; + +type TrackFutureChangeDecoratorParams = { + params: unknown[]; +}; + +const dragCallbackByCodename: { + [codename: string]: (name: string) => void; +} = { + nodeDragStart: startSnapshotWatching, + nodeDragChange: processSnapshotWatching, + nodeDragStop: stopSnapshotWatching, +}; + +export function trackFutureChangeDecorator({ params }: TrackFutureChangeDecoratorParams) { + const codename = typeof params[0] === 'string' ? params[0] : ''; + + if (['undo', 'redo'].includes(codename)) { + // Triggered by history skipping takeSnapshot() + return; + } + + const specialDragCallback = dragCallbackByCodename[codename]; + if (specialDragCallback) { + specialDragCallback('nodeDrag'); + + return; + } + + takeSnapshot(); +} diff --git a/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx new file mode 100644 index 000000000..77f872f9b --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/hooks/use-undo-redo-keyboard-handler.tsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +import { redo, undo } from '../stores/use-undo-redo-store'; + +function isTextTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + +export const useUndoRedoKeyboardHandler = () => { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + // `event.repeat` guards held-key OS auto-repeat; text fields keep their native undo. + if (!(event.ctrlKey || event.metaKey) || event.repeat || isTextTarget(event.target)) { + return; + } + const key = event.key.toLowerCase(); + if (key === 'z') { + event.preventDefault(); + if (event.shiftKey) { + redo(); + } else { + undo(); + } + } else if (key === 'y') { + event.preventDefault(); + redo(); + } + } + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); +}; diff --git a/apps/ai-studio/src/plugins/undo-redo/locales/en/translation.json b/apps/ai-studio/src/plugins/undo-redo/locales/en/translation.json new file mode 100644 index 000000000..d9800588f --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/locales/en/translation.json @@ -0,0 +1,8 @@ +{ + "plugins": { + "undoRedo": { + "undo": "Undo", + "redo": "Redo" + } + } +} diff --git a/apps/ai-studio/src/plugins/undo-redo/locales/pl/translation.json b/apps/ai-studio/src/plugins/undo-redo/locales/pl/translation.json new file mode 100644 index 000000000..d02a682f7 --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/locales/pl/translation.json @@ -0,0 +1,8 @@ +{ + "plugins": { + "undoRedo": { + "undo": "Cofnij", + "redo": "Ponów" + } + } +} diff --git a/apps/ai-studio/src/plugins/undo-redo/plugin-exports.ts b/apps/ai-studio/src/plugins/undo-redo/plugin-exports.ts new file mode 100644 index 000000000..9e4e45ec7 --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/plugin-exports.ts @@ -0,0 +1,32 @@ +import { registerComponentDecorator, registerFunctionDecorator, registerPluginTranslation } from '@workflowbuilder/sdk'; + +import { ButtonsUndoRedo } from './components/buttons-undo-redo/buttons-undo-redo'; +import { trackFutureChangeDecorator } from './functions/decorators'; +import * as translationEN from './locales/en/translation.json'; +import * as translationPL from './locales/pl/translation.json'; +import { UndoRedoProvider } from './providers/undo-redo-provider'; + +export function plugin(): void { + registerComponentDecorator('OptionalHooks', { + content: UndoRedoProvider, + }); + + registerComponentDecorator('OptionalAppBarTools', { + content: ButtonsUndoRedo, + place: 'after', + name: 'UndoRedo', + }); + + registerFunctionDecorator('trackFutureChange', { + callback: trackFutureChangeDecorator, + }); + + registerPluginTranslation({ + en: { + translation: translationEN, + }, + pl: { + translation: translationPL, + }, + }); +} diff --git a/apps/ai-studio/src/plugins/undo-redo/providers/undo-redo-provider.tsx b/apps/ai-studio/src/plugins/undo-redo/providers/undo-redo-provider.tsx new file mode 100644 index 000000000..dc21afffa --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/providers/undo-redo-provider.tsx @@ -0,0 +1,15 @@ +import { type ReactNode, memo } from 'react'; + +import { useUndoRedoKeyboardHandler } from '../hooks/use-undo-redo-keyboard-handler'; + +type UndoRedoProviderProps = { + children: ReactNode; +}; + +function UndoRedoProviderComponent({ children }: UndoRedoProviderProps) { + useUndoRedoKeyboardHandler(); + + return children; +} + +export const UndoRedoProvider = memo(UndoRedoProviderComponent); diff --git a/apps/ai-studio/src/plugins/undo-redo/stores/use-undo-redo-store.ts b/apps/ai-studio/src/plugins/undo-redo/stores/use-undo-redo-store.ts new file mode 100644 index 000000000..e365f7d91 --- /dev/null +++ b/apps/ai-studio/src/plugins/undo-redo/stores/use-undo-redo-store.ts @@ -0,0 +1,161 @@ +import { + getStoreEdges, + getStoreLayoutDirection, + getStoreNodes, + setStoreEdges, + setStoreLayoutDirection, + setStoreNodes, + trackFutureChange, +} from '@workflowbuilder/sdk'; +import type { LayoutDirection, WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +const DEFAULT_MAX_HISTORY_SIZE = 100; + +type HistoryItem = { + nodes: WorkflowBuilderNode[]; + edges: WorkflowBuilderEdge[]; + layoutDirection: LayoutDirection; +}; + +type SnapshotWatchers = { + [name: string]: { + snapshot?: HistoryItem; + }; +}; + +type UndoRedoStore = { + snapshotsWatchers: SnapshotWatchers; + past: HistoryItem[]; + future: HistoryItem[]; +}; + +const emptyStore: UndoRedoStore = { + snapshotsWatchers: {}, + past: [], + future: [], +}; + +export const useUndoRedoStore = create()( + devtools( + () => + ({ + ...emptyStore, + }) satisfies UndoRedoStore, + { name: 'undoRedoStore' }, + ), +); + +function areSnapshotsEqual(previous: HistoryItem, current: HistoryItem): boolean { + if ( + previous.nodes === current.nodes && + previous.edges === current.edges && + previous.layoutDirection === current.layoutDirection + ) { + return true; + } + + return JSON.stringify(previous) === JSON.stringify(current); +} + +export function takeSnapshot(snapshot?: HistoryItem) { + const nodes = snapshot?.nodes || getStoreNodes(); + const edges = snapshot?.edges || getStoreEdges(); + const layoutDirection = snapshot?.layoutDirection || getStoreLayoutDirection(); + + const newSnapshot: HistoryItem = { nodes, edges, layoutDirection }; + + const lastSnapshot = useUndoRedoStore.getState().past.at(-1); + if (lastSnapshot && areSnapshotsEqual(lastSnapshot, newSnapshot)) { + return; + } + + useUndoRedoStore.setState((state) => ({ + past: [...state.past.slice(state.past.length - DEFAULT_MAX_HISTORY_SIZE + 1), newSnapshot], + future: [], + })); +} + +export function startSnapshotWatching(name: string) { + useUndoRedoStore.setState((state) => ({ + snapshotsWatchers: { + ...state.snapshotsWatchers, + [name]: { + snapshot: { + nodes: getStoreNodes(), + edges: getStoreEdges(), + layoutDirection: getStoreLayoutDirection(), + }, + }, + }, + })); +} + +export function stopSnapshotWatching(name: string) { + useUndoRedoStore.setState((state) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [name]: snapshotRemoved, ...newSnapshotsWatchers } = state.snapshotsWatchers; + + return { + snapshotsWatchers: newSnapshotsWatchers, + }; + }); +} + +export function processSnapshotWatching(name: string, shouldSkip = false) { + const item = useUndoRedoStore.getState().snapshotsWatchers[name]; + + if (!item?.snapshot) { + return; + } + + if (!shouldSkip) { + takeSnapshot(item.snapshot); + stopSnapshotWatching(name); + } +} + +export function undo() { + trackFutureChange('undo'); + + const state = useUndoRedoStore.getState(); + const pastState = state.past.at(-1); + + if (pastState) { + const nodes = getStoreNodes(); + const edges = getStoreEdges(); + const layoutDirection = getStoreLayoutDirection(); + + useUndoRedoStore.setState((state) => ({ + past: state.past.slice(0, -1), + future: [...state.future, { nodes, edges, layoutDirection }], + })); + + setStoreNodes(pastState.nodes); + setStoreEdges(pastState.edges); + setStoreLayoutDirection(pastState.layoutDirection); + } +} + +export function redo() { + trackFutureChange('redo'); + + const state = useUndoRedoStore.getState(); + const futureState = state.future.at(-1); + + if (futureState) { + const nodes = getStoreNodes(); + const edges = getStoreEdges(); + const layoutDirection = getStoreLayoutDirection(); + + useUndoRedoStore.setState((state) => ({ + past: [...state.past, { nodes, edges, layoutDirection }], + future: state.future.slice(0, -1), + })); + + setStoreNodes(futureState.nodes); + setStoreEdges(futureState.edges); + setStoreLayoutDirection(futureState.layoutDirection); + } +} diff --git a/apps/ai-studio/src/security/turnstile.ts b/apps/ai-studio/src/security/turnstile.ts new file mode 100644 index 000000000..9b37fec71 --- /dev/null +++ b/apps/ai-studio/src/security/turnstile.ts @@ -0,0 +1,94 @@ +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 undefined when no site key is configured (local dev). One invisible widget re-executed per run, since tokens are single-use. +export function getTurnstileToken(): Promise { + // Serialized: the single widget and resolve/reject slots can't handle overlapping requests. + const next = queue + .catch(() => { + // a failed predecessor must not poison the queue + }) + .then(requestToken); + queue = next; + return next; +} + +let queue: Promise = Promise.resolve(); + +async function requestToken(): 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/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index ddf74f000..563c58373 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -21,7 +21,7 @@ type ExecutionStore = { streamUrl: string | undefined; nodeStates: Record; events: ExecutionEvent[]; - selectedNodeId: string | null; + logCollapsed: boolean; }; const emptyStore: ExecutionStore = { @@ -30,7 +30,7 @@ const emptyStore: ExecutionStore = { streamUrl: undefined, nodeStates: {}, events: [], - selectedNodeId: null, + logCollapsed: false, }; export const useExecutionStore = create()( @@ -102,8 +102,12 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record ({ logCollapsed: !state.logCollapsed })); } function eventToExecutionStatus(event: ExecutionEvent): ExecutionStatus | undefined { 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..1f7a94026 --- /dev/null +++ b/apps/ai-studio/src/utils/adapt-visualization.ts @@ -0,0 +1,20 @@ +import { BACKEND_URL } from '../config'; +import { getTurnstileToken } from '../security/turnstile'; + +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..7e89896fa --- /dev/null +++ b/apps/ai-studio/src/utils/detect-format.test.ts @@ -0,0 +1,72 @@ +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'); + }); + + 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..330b7023f --- /dev/null +++ b/apps/ai-studio/src/utils/detect-format.ts @@ -0,0 +1,127 @@ +export type VisualizeRenderer = 'markdown' | 'text' | 'json' | 'table' | 'stat-cards' | 'chart' | 'diagram'; + +type DetectResult = { + renderer: VisualizeRenderer; + data?: unknown; +}; + +// Strict on purpose: flowchart/graph require a direction so prose isn't 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 detectJson(parsed: unknown): DetectResult | null { + 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 }; + } + if (parsed.length > 0 && parsed.every(isPlainObject)) { + return { renderer: 'table', data: parsed }; + } + return { renderer: 'table', data: parsed }; + } + + if (isPlainObject(parsed)) { + return Object.values(parsed).every(isScalar) + ? { renderer: 'stat-cards', data: parsed } + : { renderer: 'json', data: parsed }; + } + + return null; +} + +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); + if (counts[0] < 2 || !counts.every((c) => c === counts[0])) { + return null; + } + const headers = lines[0].split(delimiter).map((h) => h.trim()); + 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' }; + } + + 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 }; + } + + if (text.startsWith('{') || text.startsWith('[')) { + try { + const result = detectJson(JSON.parse(text)); + if (result) { + return result; + } + } catch { + // not JSON + } + } + + const csv = parseCsv(text); + if (csv) { + return { renderer: 'table', data: csv }; + } + + 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..cdfc8e139 --- /dev/null +++ b/apps/ai-studio/src/utils/export-visualization.ts @@ -0,0 +1,55 @@ +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(); +} + +export async function downloadPng(element: HTMLElement, filename = 'visualization.png'): Promise { + const dataUrl = await toPng(element, PNG_OPTIONS); + triggerDownload(dataUrl, filename); +} + +// Returns false when it falls back to a download (e.g. Firefox can't 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 back to download + } + } + triggerDownload(URL.createObjectURL(blob), 'visualization.png'); + return false; +} + +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); +} + +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..8c7df3951 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -11,3 +11,12 @@ 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= +# 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 3f1e1fb1a..045a7d4db 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,9 +18,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 a813c14f6..cc2e7543e 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -16,4 +16,9 @@ export const env = { RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')), RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')), TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true', + // Null = Turnstile verification disabled (local dev runs unprotected). + TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, + // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key. + 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..ea1039894 --- /dev/null +++ b/apps/backend/src/routes/visualize.ts @@ -0,0 +1,80 @@ +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']), +}); + +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 }>(); + + 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 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..cb7906c85 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,12 @@ export function createWorkflowsRoutes( await assertAuthorized(c, 'workflows:execute', { kind: 'workflow', workflowId }); + // The one endpoint that spends real LLM budget, so it carries the abuse 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..0c5672590 --- /dev/null +++ b/apps/backend/src/security/execution-guard.ts @@ -0,0 +1,38 @@ +import type { Context } from 'hono'; + +import { isTurnstileEnabled, verifyTurnstileToken } from './turnstile'; + +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' + ); +} + +// Optional Turnstile bot verification; no-op when unconfigured. Returns a +// Response to short-circuit the request, or null to proceed. Rate limiting is +// handled by the execute rate-limit middleware in server.ts. +export async function guardExecution(c: Context): Promise { + if (!isTurnstileEnabled()) { + return null; + } + + 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 ip = clientIp(c); + 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..3472d0146 --- /dev/null +++ b/apps/backend/src/security/turnstile.ts @@ -0,0 +1,43 @@ +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'; + +export function isTurnstileEnabled(): boolean { + return Boolean(env.TURNSTILE_SECRET_KEY); +} + +// Returns true when disabled (no secret); fails closed on a verifier/network error, since this gates a 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 d29dd2853..9a2d7773e 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -17,6 +17,7 @@ import { env } from './env'; import { logger } from './logger'; import { createRateLimitMiddleware } from './middleware/rate-limit'; import { createExecutionsRoutes } from './routes/executions'; +import { createVisualizeRoutes } from './routes/visualize'; import { createWorkflowsRoutes } from './routes/workflows'; import { NoopTenantContextPort, type TenantContextPort, type TenantVariables, createTenantMiddleware } from './tenant'; @@ -63,14 +64,14 @@ app.use('/api/*', createAuthMiddleware(authPort)); app.use('/api/*', createTenantMiddleware(tenantPort)); if (env.RATE_LIMIT_EXECUTE_PER_MINUTE > 0 || env.RATE_LIMIT_EXECUTE_PER_DAY > 0) { - app.use( - '/api/workflows/:id/execute', - createRateLimitMiddleware({ - perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE, - perDay: env.RATE_LIMIT_EXECUTE_PER_DAY, - trustProxy: env.TRUST_PROXY, - }), - ); + // Shared instance: workflow runs and Visualize "AI adapt" draw from one LLM-call budget. + const executeRateLimit = createRateLimitMiddleware({ + perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE, + perDay: env.RATE_LIMIT_EXECUTE_PER_DAY, + trustProxy: env.TRUST_PROXY, + }); + app.use('/api/workflows/:id/execute', executeRateLimit); + app.use('/api/visualize/adapt', executeRateLimit); logger.info('execute rate limit enabled', { perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE, perDay: env.RATE_LIMIT_EXECUTE_PER_DAY, @@ -80,6 +81,7 @@ if (env.RATE_LIMIT_EXECUTE_PER_MINUTE > 0 || env.RATE_LIMIT_EXECUTE_PER_DAY > 0) app.route('/api/workflows', createWorkflowsRoutes(assertAuthorized)); app.route('/api/executions', createExecutionsRoutes(assertAuthorized)); +app.route('/api/visualize', createVisualizeRoutes(assertAuthorized)); // a failure (DB still starting) exits the process; the container restart policy retries await runMigrations(); 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..cbc4e201a 100644 --- a/apps/execution-worker/src/activities/ai-agent.ts +++ b/apps/execution-worker/src/activities/ai-agent.ts @@ -1,12 +1,17 @@ -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'; + +// Bounds the agentic tool loop so a misbehaving model can't run up cost. +const MAX_TOOL_STEPS = 4; type AiAgentDeps = { model: Parameters[0]['model']; logger?: LoggerPort; + tavilyApiKey?: string; }; export async function executeAiAgent(node: AiAgentNode, context: ExecutionContext, deps: AiAgentDeps) { @@ -36,18 +41,21 @@ export async function executeAiAgent(node: AiAgentNode, context: ExecutionContex userPrompt = `Here is the context from previous steps:\n\n${parts.join('\n\n')}`; } + 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. + ...(tools ? { tools, stopWhen: stepCountIs(MAX_TOOL_STEPS) } : {}), }); return { output: { response: result.text } }; } catch (error) { - // Mirror the `node_failed` event payload shape (`{ error: { message, code? } }`) - // so operators correlating a log line with the SSE event by executionId see - // the same structure on both sides. + // Mirror the `node_failed` SSE payload shape so a log line and the event line up by executionId. const message = error instanceof Error ? error.message : String(error); deps.logger?.error('llm call failed', { workflowId: context.workflowId, diff --git a/apps/execution-worker/src/domain/ai-studio-nodes.ts b/apps/execution-worker/src/domain/ai-studio-nodes.ts index 131fb0a73..2e331abfd 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; // needs TAVILY_API_KEY to take effect }; export type DecisionBranchCondition = { @@ -27,6 +28,9 @@ type DecisionNodeConfig = { decisionBranches: DecisionBranch[]; }; +// Display-only node; the UI reads the upstream output directly, so no runtime config. +type VisualizeNodeConfig = Record; + export type TriggerNode = { id: string; type: 'ai-studio/trigger'; @@ -45,4 +49,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 401c60e93..c941b41bc 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..c86338aa9 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -17,5 +17,8 @@ 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 over frontier capability. + AI_MODEL: envOr('AI_MODEL', 'google/gemini-2.5-flash-lite'), + // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. + 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..1ce3587d2 --- /dev/null +++ b/apps/execution-worker/src/executors/visualize.ts @@ -0,0 +1,4 @@ +// Display-only node: the executor just has to complete so the on-canvas card reveals. +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..085977447 --- /dev/null +++ b/apps/execution-worker/src/tools/web-search.ts @@ -0,0 +1,55 @@ +import { jsonSchema, tool } from 'ai'; + +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[] }; + +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 f2ec0e8c9..f18eda6ba 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,33 @@ 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-i18next: + specifier: ^15.4.1 + version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@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)) @@ -143,7 +161,7 @@ importers: version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(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)) + version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(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)) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4) @@ -153,6 +171,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 +183,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 @@ -207,7 +231,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) @@ -440,7 +464,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) @@ -723,6 +747,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'} @@ -785,6 +812,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'} @@ -1574,6 +1604,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'} @@ -1910,6 +1943,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==} @@ -2198,6 +2234,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'} @@ -2406,6 +2453,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'} @@ -2664,24 +2714,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==} @@ -2706,6 +2831,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==} @@ -2797,6 +2925,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==} @@ -2854,6 +2985,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'} @@ -3518,6 +3652,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==} @@ -3589,6 +3727,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'} @@ -3670,10 +3814,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'} @@ -3682,18 +3867,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'} @@ -3708,6 +3963,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'} @@ -3727,8 +3989,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==} @@ -3751,6 +4013,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==} @@ -3775,6 +4040,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'} @@ -3852,6 +4120,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==} @@ -4520,6 +4791,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'} @@ -4635,6 +4909,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==} @@ -4705,6 +4982,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'} @@ -4738,6 +5018,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==} @@ -5042,9 +5329,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'} @@ -5068,6 +5362,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'} @@ -5199,6 +5499,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'} @@ -5296,6 +5601,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==} @@ -5716,6 +6024,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'} @@ -5784,6 +6095,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'} @@ -5934,6 +6251,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'} @@ -5950,6 +6273,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'} @@ -6012,6 +6347,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==} @@ -6026,6 +6369,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==} @@ -6123,6 +6474,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'} @@ -6170,11 +6524,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'} @@ -6185,6 +6545,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==} @@ -6483,6 +6846,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==} @@ -6579,6 +6945,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==} @@ -6650,6 +7019,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} @@ -6966,6 +7339,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 @@ -7723,6 +8099,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 @@ -7872,6 +8250,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 @@ -8549,6 +8929,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 @@ -8843,12 +9229,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) @@ -8902,6 +9288,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 @@ -9162,6 +9552,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 @@ -9343,6 +9745,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 @@ -9494,12 +9898,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) @@ -9666,18 +10070,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 @@ -9687,6 +10154,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 @@ -9722,6 +10222,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 @@ -9807,6 +10309,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': @@ -9893,6 +10397,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))': @@ -10761,6 +11270,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + common-ancestor-path@1.0.1: {} compare-func@2.0.0: @@ -10824,6 +11335,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 @@ -10923,8 +11442,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: @@ -10932,14 +11493,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): @@ -10959,6 +11588,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 @@ -10984,7 +11651,7 @@ snapshots: date-fns@4.1.0: {} - dayjs@1.11.13: {} + dayjs@1.11.21: {} de-indent@1.0.2: {} @@ -10996,6 +11663,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: @@ -11020,6 +11689,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: {} @@ -11085,6 +11758,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 @@ -11900,6 +12577,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: {} @@ -12133,6 +12812,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: {} @@ -12212,6 +12893,8 @@ snapshots: immer@10.1.1: {} + immer@11.1.8: {} + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -12237,6 +12920,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: {} @@ -12561,10 +13248,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: {} @@ -12591,6 +13284,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 @@ -12726,6 +13423,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} md5@2.3.0: @@ -12952,6 +13651,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 @@ -13565,6 +14288,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -13612,6 +14337,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): @@ -13770,6 +14502,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 @@ -13783,6 +14533,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): @@ -13843,6 +14602,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 @@ -13872,6 +14651,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: @@ -14035,6 +14820,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -14090,6 +14877,8 @@ snapshots: rgbcolor@1.0.1: optional: true + robust-predicates@3.0.3: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -14121,6 +14910,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 @@ -14137,6 +14933,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -14510,6 +15308,8 @@ snapshots: stylis@4.2.0: {} + stylis@4.4.0: {} + suf-log@2.5.3: dependencies: s.color: 0.0.15 @@ -14605,6 +15405,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -14656,6 +15458,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 @@ -14945,6 +15749,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): From 266f9818b9ed91bdef0967464990168b0aa68f64 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 3 Jul 2026 17:25:04 +0200 Subject: [PATCH 07/14] fix(ai-studio): live execution progress behind buffering proxies + visualize copy feedback (#62) * fix(backend): disable proxy buffering on the execution SSE stream A fronting proxy that buffers the stream delivers every event in one burst at stream close, so live node progress renders only after the run finishes. Send X-Accel-Buffering: no from the route and re-emit it from the deploy nginx (nginx hides upstream X-Accel-* headers), so a TLS-terminating proxy in front of the stack sees it too. * fix(ai-studio): drop copy-source and give copy-image visible feedback The visualize copy buttons gave no feedback at all, so a successful copy looked broken. Copy image now flips to a checkmark for 1.5s and is disabled while AI adaptation is in flight (its target element does not exist yet, so clicking was a silent no-op). Copy source is removed from both the card and the modal. * feat(ai-studio): visualize copy writes image and plain text together One clipboard entry with both image/png and text/plain: pasting yields the image where images are accepted and the raw result text in text-only fields. Copying only the image looked broken when pasted into a text field. * fix(ai-studio): set Render as to auto in template visualize nodes Palette drops get mode: 'auto' from defaultPropertiesData, but the hand-authored template nodes omitted it, so their Render as select showed an empty value. --------- Co-authored-by: Jan Librowski --- .../visualize/copy-result-button.spec.tsx | 79 +++++++++++++++++++ .../visualize/copy-result-button.tsx | 40 ++++++++++ .../visualize/visualize-card.module.css | 6 ++ .../components/visualize/visualize-card.tsx | 26 +++--- .../components/visualize/visualize-modal.tsx | 22 +----- apps/ai-studio/src/data/ai-debate-flow.ts | 1 + .../src/data/content-repurposer-flow.ts | 1 + apps/ai-studio/src/data/meeting-notes-flow.ts | 1 + apps/ai-studio/src/data/research-flow.ts | 1 + .../ai-studio/src/data/support-triage-flow.ts | 1 + .../src/utils/export-visualization.ts | 20 +++-- apps/backend/src/routes/executions.ts | 7 ++ deploy/ai-studio/nginx/default.conf | 6 ++ 13 files changed, 165 insertions(+), 46 deletions(-) create mode 100644 apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx create mode 100644 apps/ai-studio/src/components/visualize/copy-result-button.tsx diff --git a/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx new file mode 100644 index 000000000..e3837defe --- /dev/null +++ b/apps/ai-studio/src/components/visualize/copy-result-button.spec.tsx @@ -0,0 +1,79 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { copyResult } from '../../utils/export-visualization'; +import { CopyResultButton } from './copy-result-button'; + +vi.mock('../../utils/export-visualization', () => ({ + copyResult: vi.fn(), +})); + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +describe('CopyResultButton', () => { + let container: HTMLDivElement; + let root: ReturnType; + const target = document.createElement('div'); + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.mocked(copyResult).mockReset(); + }); + + function render(getTarget: () => HTMLElement | null = () => target) { + act(() => { + root.render(); + }); + return container.querySelector('button')!; + } + + it('shows Copied feedback and reverts after the timeout', async () => { + vi.mocked(copyResult).mockResolvedValue(true); + const button = render(); + expect(button.title).toBe('Copy result'); + + await click(button); + expect(copyResult).toHaveBeenCalledWith(target, 'raw result'); + expect(button.title).toBe('Copied'); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button.title).toBe('Copy result'); + }); + + it('shows no feedback when the copy fell back to a download', async () => { + vi.mocked(copyResult).mockResolvedValue(false); + const button = render(); + + await click(button); + expect(button.title).toBe('Copy result'); + }); + + it('does nothing without a target', async () => { + const button = render(() => null); + + await click(button); + expect(copyResult).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-studio/src/components/visualize/copy-result-button.tsx b/apps/ai-studio/src/components/visualize/copy-result-button.tsx new file mode 100644 index 000000000..7b3100de0 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/copy-result-button.tsx @@ -0,0 +1,40 @@ +import { Check, Copy } from '@phosphor-icons/react'; +import { useEffect, useRef, useState } from 'react'; + +import { copyResult } from '../../utils/export-visualization'; + +type Props = { + className: string; + getTarget: () => HTMLElement | null; + text: string; + disabled?: boolean; +}; + +export function CopyResultButton({ className, getTarget, text, disabled }: Props) { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => globalThis.clearTimeout(timerRef.current), []); + + async function handleClick() { + const target = getTarget(); + if (!target) return; + if (await copyResult(target, text)) { + setCopied(true); + globalThis.clearTimeout(timerRef.current); + timerRef.current = globalThis.setTimeout(() => setCopied(false), 1500); + } + } + + return ( + + ); +} diff --git a/apps/ai-studio/src/components/visualize/visualize-card.module.css b/apps/ai-studio/src/components/visualize/visualize-card.module.css index a89c57d1c..90a45315a 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.module.css +++ b/apps/ai-studio/src/components/visualize/visualize-card.module.css @@ -64,6 +64,12 @@ color: var(--ax-txt-primary-default, #151516); } +.action:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + .body { max-height: 20rem; overflow-y: auto; diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index 13944b2f0..ce2bb4b27 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -1,4 +1,4 @@ -import { ArrowsOut, ClipboardText, Copy, DownloadSimple, Eye } from '@phosphor-icons/react'; +import { ArrowsOut, DownloadSimple, Eye } from '@phosphor-icons/react'; import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -8,8 +8,9 @@ import { VISUALIZE_MODES } from '../../nodes/visualize/schema'; 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 { downloadPng } from '../../utils/export-visualization'; import { extractOutputText } from '../../utils/extract-output-text'; +import { CopyResultButton } from './copy-result-button'; import { RENDERER_LABELS, getRenderer } from './renderers'; import { VisualizeModal } from './visualize-modal'; @@ -111,30 +112,21 @@ export function VisualizeCard({ props }: Props) { - + getTarget={() => contentRef.current} + text={renderText} + disabled={adapting} + /> -
diff --git a/apps/ai-studio/src/components/visualize/visualize-modal.tsx b/apps/ai-studio/src/components/visualize/visualize-modal.tsx index 775dc8717..43abe9c7e 100644 --- a/apps/ai-studio/src/components/visualize/visualize-modal.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-modal.tsx @@ -1,11 +1,12 @@ -import { ClipboardText, Copy, DownloadSimple, FileSvg, X } from '@phosphor-icons/react'; +import { 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 { downloadPng, downloadSvg } from '../../utils/export-visualization'; +import { CopyResultButton } from './copy-result-button'; import { getRenderer } from './renderers'; type Props = { @@ -29,14 +30,7 @@ export function VisualizeModal({ renderer, text, data, badge, isVector, onClose Visualize {badge}
- + contentRef.current} text={text} /> )} - diff --git a/apps/ai-studio/src/data/ai-debate-flow.ts b/apps/ai-studio/src/data/ai-debate-flow.ts index d469b66e3..2a120bfec 100644 --- a/apps/ai-studio/src/data/ai-debate-flow.ts +++ b/apps/ai-studio/src/data/ai-debate-flow.ts @@ -103,6 +103,7 @@ Be decisive.`, properties: { label: 'Visualize', description: 'Renders the verdict (auto-detects the format).', + mode: 'auto', }, type: 'ai-studio/visualize', icon: 'Eye', diff --git a/apps/ai-studio/src/data/content-repurposer-flow.ts b/apps/ai-studio/src/data/content-repurposer-flow.ts index df469fc47..0f65b9f6d 100644 --- a/apps/ai-studio/src/data/content-repurposer-flow.ts +++ b/apps/ai-studio/src/data/content-repurposer-flow.ts @@ -136,6 +136,7 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`, properties: { label: 'Visualize', description: 'Renders the content pack (auto-detects the format).', + mode: 'auto', }, type: 'ai-studio/visualize', icon: 'Eye', diff --git a/apps/ai-studio/src/data/meeting-notes-flow.ts b/apps/ai-studio/src/data/meeting-notes-flow.ts index 7189a5498..d12ea647f 100644 --- a/apps/ai-studio/src/data/meeting-notes-flow.ts +++ b/apps/ai-studio/src/data/meeting-notes-flow.ts @@ -105,6 +105,7 @@ line "_Meeting Bot_".`, properties: { label: 'Visualize', description: 'Renders the recap (auto-detects the format).', + mode: 'auto', }, type: 'ai-studio/visualize', icon: 'Eye', diff --git a/apps/ai-studio/src/data/research-flow.ts b/apps/ai-studio/src/data/research-flow.ts index 444006d97..64f39ab69 100644 --- a/apps/ai-studio/src/data/research-flow.ts +++ b/apps/ai-studio/src/data/research-flow.ts @@ -63,6 +63,7 @@ Only state things you found via search. If a claim isn't supported by a result, properties: { label: 'Visualize', description: 'Renders the research brief (auto-detects the format).', + mode: 'auto', }, type: 'ai-studio/visualize', icon: 'Eye', diff --git a/apps/ai-studio/src/data/support-triage-flow.ts b/apps/ai-studio/src/data/support-triage-flow.ts index 7fa6b06a6..e98f5454e 100644 --- a/apps/ai-studio/src/data/support-triage-flow.ts +++ b/apps/ai-studio/src/data/support-triage-flow.ts @@ -228,6 +228,7 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`, properties: { label: 'Visualize', description: 'Visualizes the approved reply (auto-detects the format).', + mode: 'auto', }, type: 'ai-studio/visualize', icon: 'Eye', diff --git a/apps/ai-studio/src/utils/export-visualization.ts b/apps/ai-studio/src/utils/export-visualization.ts index cdfc8e139..29005a7de 100644 --- a/apps/ai-studio/src/utils/export-visualization.ts +++ b/apps/ai-studio/src/utils/export-visualization.ts @@ -14,8 +14,10 @@ export async function downloadPng(element: HTMLElement, filename = 'visualizatio triggerDownload(dataUrl, filename); } +// Writes both formats in one clipboard entry: pasting yields the image where +// images are accepted and the plain text of the result in text-only fields. // Returns false when it falls back to a download (e.g. Firefox can't write image blobs to the clipboard). -export async function copyImage(element: HTMLElement): Promise { +export async function copyResult(element: HTMLElement, text: string): Promise { const blob = await toBlob(element, PNG_OPTIONS); if (!blob) { return false; @@ -23,7 +25,12 @@ export async function copyImage(element: HTMLElement): Promise { const canCopyImage = typeof ClipboardItem !== 'undefined' && Boolean(navigator.clipboard?.write); if (canCopyImage) { try { - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob, + 'text/plain': new Blob([text], { type: 'text/plain' }), + }), + ]); return true; } catch { // fall back to download @@ -44,12 +51,3 @@ export async function downloadSvg(element: HTMLElement, filename = 'visualizatio const dataUrl = await toSvg(element); triggerDownload(dataUrl, filename); } - -export async function copySource(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/apps/backend/src/routes/executions.ts b/apps/backend/src/routes/executions.ts index 0934f645b..57580ba27 100644 --- a/apps/backend/src/routes/executions.ts +++ b/apps/backend/src/routes/executions.ts @@ -85,6 +85,13 @@ export function createExecutionsRoutes( return c.json({ code: 'execution_not_found', message: 'Execution not found' }, 404); } + // Any nginx hop between us and the browser buffers this stream into its + // proxy buffers by default and flushes only at stream close, turning live + // progress into one end-of-run burst. nginx honors this header from the + // upstream response and disables buffering per-response, covering hops + // whose config we don't own (e.g. a TLS-terminating host nginx). + c.header('X-Accel-Buffering', 'no'); + return streamSSE(c, async (stream) => { // Catch-up snapshot. Reuses the same incremental query (afterSequence=0) // that powers live drains — one query shape across the route, not two. diff --git a/deploy/ai-studio/nginx/default.conf b/deploy/ai-studio/nginx/default.conf index 501c28d4f..c3677d506 100644 --- a/deploy/ai-studio/nginx/default.conf +++ b/deploy/ai-studio/nginx/default.conf @@ -34,6 +34,12 @@ server { proxy_cache off; proxy_read_timeout 1h; gzip off; + # nginx hides upstream X-Accel-* headers, so the backend's own + # X-Accel-Buffering dies at this hop. Re-emit it for whatever + # TLS-terminating proxy sits in front (README "TLS / going public"); + # a default-config host nginx would otherwise buffer the whole + # stream and deliver it only at stream close. + add_header X-Accel-Buffering no; } location /api/ { From c5bff344ffd400ca2f822aa89498ec12a7c3f7c1 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Mon, 6 Jul 2026 11:12:16 +0200 Subject: [PATCH 08/14] feat(ai-studio): floating button reopens the welcome modal (#63) The disclaimer showed only on first visit with no way back to it. When the modal is closed, an info button in the canvas's bottom-right corner brings it up again. Co-authored-by: Jan Librowski --- .../disclaimer/disclaimer-modal.module.css | 22 +++++++++++++++++++ .../disclaimer/disclaimer-modal.tsx | 12 ++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css index 1e041f2fb..24fe2fe2b 100644 --- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css @@ -109,3 +109,25 @@ .cta:hover { background: var(--ax-colors-acc1-600, #0477c5); } + +.reopen { + position: fixed; + right: 1.5rem; + bottom: 2.5rem; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border: 0.0625rem solid var(--wb-app-bar-border-color); + border-radius: 50%; + background: var(--wb-app-bar-background); + color: var(--ax-txt-secondary-default, #4d5059); + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.reopen:hover { + color: var(--ax-colors-acc1-500, #1096e7); +} diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx index 38d83d966..eaa5138e0 100644 --- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx @@ -16,10 +16,6 @@ function hasAcknowledged(): boolean { export function DisclaimerModal() { const [open, setOpen] = useState(() => !hasAcknowledged()); - if (!open) { - return null; - } - function dismiss() { try { localStorage.setItem(STORAGE_KEY, 'true'); @@ -29,6 +25,14 @@ export function DisclaimerModal() { setOpen(false); } + if (!open) { + return ( + + ); + } + return (
Date: Wed, 15 Jul 2026 09:58:32 +0200 Subject: [PATCH 09/14] feat(ai-studio): dock execution log beside the properties panel (#64) * feat(ai-studio): dock execution log beside the properties panel The log is no longer a centered overlay covering the canvas. It floats bottom-right: against the left edge of the properties panel when the panel is expanded, at the viewport edge otherwise. - height grows with entries up to 430px instead of a fixed 60vh - auto-scroll follows the newest entry; manual scroll pauses it, returning to the bottom resumes it - collapse state persists in sessionStorage for the session - starting an execution reopens a collapsed log * fix(ai-studio): make the whole log entry a collapse toggle The gray detail box showed a pointer cursor but ignored clicks; only the entry header toggled. Now the entire entry (header + detail) is one toggle area. - text selection inside the entry does not trigger the toggle, so log content stays copyable - clicks on interactive elements (links, buttons) inside the detail do not propagate to the toggle * fix(ai-studio): hide the welcome reopen button when the corner is busy The floating info button shares the bottom-right corner with the execution log and overlapped the expanded properties panel footer. Hide it while the log is on screen or the properties panel is expanded; it returns once the corner is free. * style(ai-studio): cut comments down to the non-obvious * refactor(ai-studio): replace toggle guards and unit comment with self-evident code * style(ai-studio): restore rem unit for the log panel max-height * refactor(ai-studio): self-review pass on the execution log changes - full-word selector params (state, not s) across touched files - named constants for magic numbers: scroll bottom tolerance, detail preview length, node id preview length, expanded-panel height ratio - anchor hook decomposed into findRightPanel / measureAnchor / observeAnchor / sameAnchor instead of one closure with an alias - toggleLog reuses setLogCollapsed instead of a side effect inside the setState updater; persist helper renamed to persistLogCollapsed - clsx for conditional class lists instead of template strings - dropped a dead styles lookup (event-- classes do not exist) * fix(ai-studio): toggle log entries from the header only Clicking the detail box collapsed the entry under the cursor, which fought with reading and copying the content. The toggle now lives on the header row alone and the detail is plain selectable text without a pointer affordance. Refines the toggle behavior after hands-on testing; diverges from the ticket's whole-entry-toggle wording, ticket to be updated. * revert(ai-studio): restore the whole-entry log toggle Reverts the header-only constraint - the ticket spec is correct as written: header and detail box form one toggle area, with text selection and interactive elements guarded. * refactor(ai-studio): apply review feedback on the log changes - persist the log collapse state via zustand persist middleware with partialize and sessionStorage instead of hand-rolled helpers; resetExecution keeps the current value through the updater - is-prefix for boolean variables: isLogCollapsed, isPanelExpanded, isLogVisible, isOpen, isExpanded, isHighlighted, isCollapsed --------- Co-authored-by: Jan Librowski --- .../disclaimer/disclaimer-modal.tsx | 15 +++- .../components/execution/log-panel.module.css | 17 ++-- .../src/components/execution/log-panel.tsx | 86 +++++++++++++------ .../src/hooks/use-right-panel-anchor.ts | 64 ++++++++++++++ .../src/stores/use-execution-store.ts | 24 ++++-- 5 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 apps/ai-studio/src/hooks/use-right-panel-anchor.ts diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx index eaa5138e0..01961e551 100644 --- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx @@ -3,6 +3,9 @@ import { useState } from 'react'; import styles from './disclaimer-modal.module.css'; +import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor'; +import { useExecutionStore } from '../../stores/use-execution-store'; + const STORAGE_KEY = 'ai-studio:disclaimer-acknowledged'; function hasAcknowledged(): boolean { @@ -14,7 +17,9 @@ function hasAcknowledged(): boolean { } export function DisclaimerModal() { - const [open, setOpen] = useState(() => !hasAcknowledged()); + const [isOpen, setIsOpen] = useState(() => !hasAcknowledged()); + const isLogVisible = useExecutionStore((state) => state.events.length > 0 || state.status !== 'idle'); + const { isPanelExpanded } = useRightPanelAnchor(); function dismiss() { try { @@ -22,12 +27,14 @@ export function DisclaimerModal() { } catch { // storage unavailable } - setOpen(false); + setIsOpen(false); } - if (!open) { + if (!isOpen) { + if (isLogVisible || isPanelExpanded) return null; + return ( - ); 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 20627114d..ec687cd99 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -1,11 +1,10 @@ .panel { position: fixed; - bottom: 1.5rem; - left: 50%; - transform: translateX(-50%); + bottom: 1rem; + right: var(--log-panel-right, 1rem); width: 30rem; max-width: calc(100vw - 3rem); - max-height: 60vh; + max-height: 26.875rem; background: var(--wb-app-bar-background); border: 0.0625rem solid var(--wb-app-bar-border-color); border-radius: var(--wb-app-bar-border-radius); @@ -63,15 +62,14 @@ } } +.event--toggleable { + cursor: pointer; +} + .event-header { display: flex; align-items: center; gap: 0.375rem; - cursor: default; -} - -.event-header:has(+ .detail) { - cursor: pointer; } .badge { @@ -126,7 +124,6 @@ word-break: break-word; line-height: 1.5; font-size: 0.7rem; - cursor: pointer; } .detail--expanded { diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index f3781c840..31bf79880 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -1,23 +1,29 @@ import { useSingleSelectedElement } from '@workflowbuilder/sdk'; +import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events'; import styles from './log-panel.module.css'; +import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor'; import { toggleLog, useExecutionStore } from '../../stores/use-execution-store'; import { extractOutputText } from '../../utils/extract-output-text'; -function formatTime(iso: string) { - return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const DETAIL_PREVIEW_CHARS = 120; +const NODE_ID_PREVIEW_CHARS = 8; +const AT_BOTTOM_TOLERANCE_PX = 4; + +function formatTime(isoTimestamp: string) { + return new Date(isoTimestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNodeId: string | null }) { - const [expanded, setExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const nodeId = (event as { nodeId?: string | null }).nodeId; const isNode = typeof nodeId === 'string' && nodeId.length > 0; - const highlighted = isNode && nodeId === selectedNodeId; + const isHighlighted = isNode && nodeId === selectedNodeId; const label = event.type.replaceAll('_', ' '); let detail: string | undefined; @@ -41,22 +47,36 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo } const hasDetail = !!detail; - const truncated = detail && detail.length > 120 ? detail.slice(0, 120) + '…' : detail; + const truncated = + detail && detail.length > DETAIL_PREVIEW_CHARS ? detail.slice(0, DETAIL_PREVIEW_CHARS) + '…' : detail; + + function handleToggle({ target }: React.MouseEvent) { + const clickedInteractiveElement = target instanceof Element && !!target.closest('a, button'); + const isSelectingText = !!globalThis.getSelection()?.toString(); + + if (hasDetail && !clickedInteractiveElement && !isSelectingText) { + setIsExpanded((current) => !current); + } + } return (
-
hasDetail && setExpanded((v) => !v)}> - {label} - {isNode && {(event as { nodeId: string }).nodeId.slice(0, 8)}} +
+ {label} + {isNode && {nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}} {formatTime(event.timestamp)} - {hasDetail && {expanded ? '▲' : '▼'}} + {hasDetail && {isExpanded ? '▲' : '▼'}}
{hasDetail && ( -
- {expanded ? detail : truncated} +
+ {isExpanded ? detail : truncated}
)}
@@ -64,37 +84,55 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo } export function ExecutionLogPanel() { - const events = useExecutionStore((s) => s.events); - const status = useExecutionStore((s) => s.status); - const collapsed = useExecutionStore((s) => s.logCollapsed); + const events = useExecutionStore((state) => state.events); + const status = useExecutionStore((state) => state.status); + const executionId = useExecutionStore((state) => state.executionId); + const isCollapsed = useExecutionStore((state) => state.isLogCollapsed); // Clicking a node (incl. its flag marker) selects it on the canvas; the // highlight derives from that selection, so it clears on deselect. const selectedNodeId = useSingleSelectedElement()?.node?.id ?? null; + const { rightOffset } = useRightPanelAnchor(); const bodyRef = useRef(null); + const stickToBottomRef = useRef(true); + + useEffect(() => { + stickToBottomRef.current = true; + }, [executionId]); useEffect(() => { - if (!collapsed && bodyRef.current) { + if (!isCollapsed && stickToBottomRef.current && bodyRef.current) { bodyRef.current.scrollTop = bodyRef.current.scrollHeight; } - }, [events.length, collapsed]); + }, [events.length, isCollapsed]); useEffect(() => { - if (!selectedNodeId || collapsed) return; + if (!selectedNodeId || isCollapsed) return; bodyRef.current?.querySelector(`[data-node-id="${selectedNodeId}"]`)?.scrollIntoView({ block: 'nearest' }); - }, [selectedNodeId, collapsed]); + }, [selectedNodeId, isCollapsed]); + + function handleBodyScroll() { + const body = bodyRef.current; + if (!body) return; + + const distanceFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight; + stickToBottomRef.current = distanceFromBottom < AT_BOTTOM_TOLERANCE_PX; + } if (events.length === 0 && status === 'idle') return null; return ( -
+
Execution Log - {status} - {collapsed ? '▲' : '▼'} + {status} + {isCollapsed ? '▲' : '▼'}
- {!collapsed && ( -
+ {!isCollapsed && ( +
{events.map((event) => ( ))} diff --git a/apps/ai-studio/src/hooks/use-right-panel-anchor.ts b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts new file mode 100644 index 000000000..ac1227109 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; + +const GAP_PX = 16; +const EXPANDED_HEIGHT_RATIO = 0.9; + +type RightPanelAnchor = { + isPanelExpanded: boolean; + rightOffset: number; +}; + +const collapsedAnchor: RightPanelAnchor = { isPanelExpanded: false, rightOffset: GAP_PX }; + +function findRightPanel() { + const panel = document.querySelector('#viewport-bounds')?.nextElementSibling; + + return panel instanceof HTMLElement ? panel : undefined; +} + +function measureAnchor(panel: HTMLElement): RightPanelAnchor { + const sidebar = panel.firstElementChild?.firstElementChild; + if (!(sidebar instanceof HTMLElement)) return collapsedAnchor; + + const isPanelExpanded = sidebar.offsetHeight >= panel.offsetHeight * EXPANDED_HEIGHT_RATIO; + if (!isPanelExpanded) return collapsedAnchor; + + return { + isPanelExpanded: true, + rightOffset: Math.round(window.innerWidth - sidebar.getBoundingClientRect().left) + GAP_PX, + }; +} + +function sameAnchor(current: RightPanelAnchor, next: RightPanelAnchor) { + return current.isPanelExpanded === next.isPanelExpanded && current.rightOffset === next.rightOffset; +} + +function observeAnchor(panel: HTMLElement, onMeasure: (next: RightPanelAnchor) => void) { + const updateAnchor = () => onMeasure(measureAnchor(panel)); + + updateAnchor(); + + const observer = new ResizeObserver(updateAnchor); + observer.observe(panel); + window.addEventListener('resize', updateAnchor); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateAnchor); + }; +} + +// The SDK does not expose the properties panel's expanded state, so this +// measures the DOM: the panel is the element after #viewport-bounds. +export function useRightPanelAnchor(): RightPanelAnchor { + const [anchor, setAnchor] = useState(collapsedAnchor); + + useEffect(() => { + const panel = findRightPanel(); + if (!panel) return; + + return observeAnchor(panel, (next) => setAnchor((current) => (sameAnchor(current, next) ? current : next))); + }, []); + + return anchor; +} diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index 563c58373..effb405b6 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; import type { ExecutionEvent, @@ -21,7 +21,7 @@ type ExecutionStore = { streamUrl: string | undefined; nodeStates: Record; events: ExecutionEvent[]; - logCollapsed: boolean; + isLogCollapsed: boolean; }; const emptyStore: ExecutionStore = { @@ -30,15 +30,22 @@ const emptyStore: ExecutionStore = { streamUrl: undefined, nodeStates: {}, events: [], - logCollapsed: false, + isLogCollapsed: false, }; export const useExecutionStore = create()( - devtools(() => ({ ...emptyStore }), { name: 'aiStudioExecutionStore' }), + devtools( + persist(() => ({ ...emptyStore }), { + name: 'ai-studio:execution-log', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ isLogCollapsed: state.isLogCollapsed }), + }), + { name: 'aiStudioExecutionStore' }, + ), ); export function resetExecution() { - useExecutionStore.setState(emptyStore); + useExecutionStore.setState((state) => ({ ...emptyStore, isLogCollapsed: state.isLogCollapsed })); } export function setExecutionStarted(executionId: string, streamUrl: string) { @@ -48,6 +55,7 @@ export function setExecutionStarted(executionId: string, streamUrl: string) { streamUrl, nodeStates: {}, events: [], + isLogCollapsed: false, }); } @@ -102,12 +110,12 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record ({ logCollapsed: !state.logCollapsed })); + setLogCollapsed(!useExecutionStore.getState().isLogCollapsed); } function eventToExecutionStatus(event: ExecutionEvent): ExecutionStatus | undefined { From d08c29b23962cf89dfd7a7c07662bb4052e302c6 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 15 Jul 2026 13:33:59 +0200 Subject: [PATCH 10/14] fix(ai-studio): re-adapt visualization when the render format changes (#65) * fix(ai-studio): re-adapt visualization when the render format changes The adapted content was cached in bare state and reset only when the source text changed, so switching the render format kept feeding the previous format's adaptation to the new renderer (raw JSON instead of a chart) and never called the AI again. The adaptation is now an async derived value keyed by (renderer, text) in a useAdaptedVisualization hook: a stale entry is ignored by the key check instead of being reset by an effect, and each format change triggers a fresh adapt for that format. * refactor(ai-studio): abort in-flight adapt calls, record failures as data The effect now uses an inner async function with an AbortController: switching formats mid-flight cancels the HTTP request instead of just ignoring its result, and the cancelled flag is gone. A failed adapt is cached as { key, output: null } - the raw-text fallback derives from data instead of a swallowed exception, so the catch block has real content and the failure is one selector away from the UI whenever we want to surface it. * refactor(ai-studio): return render-ready text from useAdaptedVisualization The hook now owns the raw-text fallback and returns renderText plus an isAdapted flag, so the caller no longer interprets a null convention spread across files. * refactor(ai-studio): return render-ready text from useAdaptedVisualization The hook now owns the raw-text fallback and returns renderText plus an isAdapted flag, so the caller no longer interprets a null convention spread across files. --------- Co-authored-by: Jan Librowski --- .../components/visualize/visualize-card.tsx | 47 ++++------------- .../src/hooks/use-adapted-visualization.ts | 51 +++++++++++++++++++ .../src/utils/adapt-visualization.ts | 3 +- 3 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 apps/ai-studio/src/hooks/use-adapted-visualization.ts diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index ce2bb4b27..96b909bfd 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -1,12 +1,12 @@ import { ArrowsOut, DownloadSimple, Eye } from '@phosphor-icons/react'; import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; -import { Suspense, useEffect, useRef, useState } from 'react'; +import { Suspense, useRef, useState } from 'react'; import styles from './visualize-card.module.css'; +import { useAdaptedVisualization } from '../../hooks/use-adapted-visualization'; import { VISUALIZE_MODES } from '../../nodes/visualize/schema'; import { useExecutionStore } from '../../stores/use-execution-store'; -import { adaptVisualization } from '../../utils/adapt-visualization'; import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format'; import { downloadPng } from '../../utils/export-visualization'; import { extractOutputText } from '../../utils/extract-output-text'; @@ -22,7 +22,6 @@ type Props = { type VisualizeMode = VisualizeRenderer | 'auto'; const VALID_MODES = new Set(VISUALIZE_MODES); -const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); function EmptyState({ running }: { running: boolean }) { if (running) { @@ -47,9 +46,7 @@ function EmptyState({ running }: { running: boolean }) { export function VisualizeCard({ props }: Props) { const nodeId = props?.nodeId ?? ''; - const [expanded, setExpanded] = useState(false); - const [adaptedText, setAdaptedText] = useState(null); - const [adapting, setAdapting] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const contentRef = useRef(null); // Nodes/edges are static during a run, so snapshot reads are fine. @@ -69,35 +66,13 @@ export function VisualizeCard({ props }: Props) { const detection = detectFormat(text); const activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode; - useEffect(() => { - setAdaptedText(null); - }, [text]); - - useEffect(() => { - if (!hasOutput || !ADAPTABLE.has(activeRenderer) || adaptedText !== null) return; - let cancelled = false; - setAdapting(true); - adaptVisualization(text, activeRenderer) - .then((output) => { - if (!cancelled) setAdaptedText(output); - }) - .catch(() => { - // keep original content - }) - .finally(() => { - if (!cancelled) setAdapting(false); - }); - return () => { - cancelled = true; - }; - }, [hasOutput, activeRenderer, text, adaptedText]); + const { renderText, isAdapted, isAdapting } = useAdaptedVisualization(text, activeRenderer, hasOutput); if (!isVisualizeNode) { return null; } - const renderText = adaptedText ?? text; - const data = adaptedText === null && mode === 'auto' ? detection.data : undefined; + const data = !isAdapted && mode === 'auto' ? detection.data : undefined; const Renderer = hasOutput ? getRenderer(activeRenderer) : null; const badge = mode === 'auto' ? `Auto › ${RENDERER_LABELS[activeRenderer]}` : RENDERER_LABELS[activeRenderer]; const isVector = activeRenderer === 'chart' || activeRenderer === 'diagram'; @@ -109,20 +84,20 @@ export function VisualizeCard({ props }: Props) {
{badge}
- contentRef.current} text={renderText} - disabled={adapting} + disabled={isAdapting} />
- {adapting ? ( + {isAdapting ? (
@@ -151,14 +126,14 @@ export function VisualizeCard({ props }: Props) { ) : ( )} - {expanded && ( + {isExpanded && ( setExpanded(false)} + onClose={() => setIsExpanded(false)} /> )}
diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts new file mode 100644 index 000000000..701959242 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; + +import { adaptVisualization } from '../utils/adapt-visualization'; +import type { VisualizeRenderer } from '../utils/detect-format'; + +const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); + +// output null = the adapt call failed and the raw text is rendered instead. +type Adaptation = { key: string; output: string | null }; + +// The adapted content is an async derived value keyed by (renderer, text). +// A stale adaptation is ignored by the key check, never reset by an effect, +// so switching the render format re-adapts for the new one. +export function useAdaptedVisualization(text: string, renderer: VisualizeRenderer, hasOutput: boolean) { + const [adaptation, setAdaptation] = useState(null); + const [isAdapting, setIsAdapting] = useState(false); + + const adaptationKey = `${renderer}\n${text}`; + const cached = adaptation?.key === adaptationKey ? adaptation : null; + const shouldAdapt = hasOutput && ADAPTABLE.has(renderer) && cached === null; + + useEffect(() => { + if (!shouldAdapt) return; + + const controller = new AbortController(); + + async function adapt() { + setIsAdapting(true); + try { + const output = await adaptVisualization(text, renderer, controller.signal); + setAdaptation({ key: adaptationKey, output }); + } catch { + if (!controller.signal.aborted) setAdaptation({ key: adaptationKey, output: null }); + } finally { + if (!controller.signal.aborted) setIsAdapting(false); + } + } + + void adapt(); + + return () => controller.abort(); + }, [shouldAdapt, adaptationKey, text, renderer]); + + const adaptedOutput = cached?.output ?? null; + + return { + renderText: adaptedOutput ?? text, + isAdapted: adaptedOutput !== null, + isAdapting, + }; +} diff --git a/apps/ai-studio/src/utils/adapt-visualization.ts b/apps/ai-studio/src/utils/adapt-visualization.ts index 1f7a94026..3be48f2a5 100644 --- a/apps/ai-studio/src/utils/adapt-visualization.ts +++ b/apps/ai-studio/src/utils/adapt-visualization.ts @@ -1,7 +1,7 @@ import { BACKEND_URL } from '../config'; import { getTurnstileToken } from '../security/turnstile'; -export async function adaptVisualization(content: string, format: string): Promise { +export async function adaptVisualization(content: string, format: string, signal?: AbortSignal): Promise { const token = await getTurnstileToken(); const response = await fetch(`${BACKEND_URL}/api/visualize/adapt`, { method: 'POST', @@ -10,6 +10,7 @@ export async function adaptVisualization(content: string, format: string): Promi ...(token ? { 'cf-turnstile-token': token } : {}), }, body: JSON.stringify({ content, format }), + signal, }); if (!response.ok) { const error = (await response.json().catch(() => ({}))) as { message?: string }; From abbe61b63c855142f5da9ef5a697e7631e3806bf Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 22 Jul 2026 13:21:56 +0200 Subject: [PATCH 11/14] fix(deploy): complete the LLM env wiring for the demo stack (#66) * fix(deploy): pass OPENROUTER_API_KEY to the backend service The adapt endpoint runs its LLM call in the backend, not the worker, and answered 501 adapt_disabled on every request because only the worker received the key. Executions kept working, which masked it. * fix(deploy): wire TAVILY_API_KEY into the worker service The worker reads TAVILY_API_KEY to enable the AI Agent's web search tool, but the deploy compose never passed it, so web search was silently disabled on deployments: agents with the toggle on ran without the tool. Optional - an empty value keeps it off. * fix: unify the AI_MODEL default on the demo model Backend and worker code defaulted to google/gemini-2.5-flash-lite while the deploy stack defaults to mistralai/mistral-small-3.2-24b-instruct, so an unset AI_MODEL meant different models depending on where the process ran. One default everywhere now - the deployed demo already runs this model, so production behavior does not change. --------- Co-authored-by: Jan Librowski --- apps/backend/.env.example | 2 +- apps/backend/src/env.ts | 2 +- apps/execution-worker/.env.example | 2 +- apps/execution-worker/src/env.ts | 2 +- deploy/ai-studio/.env.example | 7 ++++++- deploy/ai-studio/docker-compose.yml | 5 +++++ 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8c7df3951..d639840f1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -19,4 +19,4 @@ TURNSTILE_SECRET_KEY= # 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 +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index cc2e7543e..278e18aa8 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -20,5 +20,5 @@ export const env = { TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null, // Null = the "AI adapt" endpoint is disabled (returns 501). The worker keeps its own key. OPENROUTER_API_KEY: process.env['OPENROUTER_API_KEY'] ?? null, - AI_MODEL: envOr('AI_MODEL', 'google/gemini-2.5-flash-lite'), + AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), }; diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 4a2aaf531..94e2ecd4c 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -3,7 +3,7 @@ TEMPORAL_ADDRESS=127.0.0.1:7233 # OpenRouter — any model OPENROUTER_API_KEY=sk-or-... -AI_MODEL=google/gemini-2.5-flash-lite +AI_MODEL=mistralai/mistral-small-3.2-24b-instruct # 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 diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index c86338aa9..5bbd6a61a 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -18,7 +18,7 @@ export const env = { TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'), OPENROUTER_API_KEY: requireEnv('OPENROUTER_API_KEY'), // Cheap, fast default for the public demo; quality-per-cost over frontier capability. - AI_MODEL: envOr('AI_MODEL', 'google/gemini-2.5-flash-lite'), + AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. TAVILY_API_KEY: process.env['TAVILY_API_KEY'], }; diff --git a/deploy/ai-studio/.env.example b/deploy/ai-studio/.env.example index b80d85932..6d250ca1d 100644 --- a/deploy/ai-studio/.env.example +++ b/deploy/ai-studio/.env.example @@ -9,10 +9,15 @@ OPENROUTER_API_KEY= # --- LLM -------------------------------------------------------------------- -# WB-229 demo model. Cheap, EU-hosted, solid tool calling. +# Demo model. Cheap, EU-hosted, solid tool calling. # ~$0.075/M input + $0.20/M output => ~$0.0004 per 3-call template run. AI_MODEL=mistralai/mistral-small-3.2-24b-instruct +# Tavily web search (optional). Enables the AI Agent's "Web search" tool - +# free key at https://tavily.com (~1000 searches/month). Leave empty to +# disable: agents with web search toggled on still run, just without the tool. +TAVILY_API_KEY= + # --- abuse gate (per-IP, execute route) --------------------------------------- RATE_LIMIT_EXECUTE_PER_MINUTE=10 diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index b891cea66..5eed67bf7 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -82,6 +82,9 @@ services: TRUST_PROXY: 'true' RATE_LIMIT_EXECUTE_PER_MINUTE: ${RATE_LIMIT_EXECUTE_PER_MINUTE:-10} RATE_LIMIT_EXECUTE_PER_DAY: ${RATE_LIMIT_EXECUTE_PER_DAY:-50} + # the backend calls the LLM itself for /api/visualize/adapt + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} + AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} depends_on: app-db: condition: service_healthy @@ -111,6 +114,8 @@ services: TEMPORAL_ADDRESS: temporal:7233 OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + # optional - empty disables the AI Agent's web search tool + TAVILY_API_KEY: ${TAVILY_API_KEY:-} depends_on: app-db: condition: service_healthy From 70468abeefb2b2458fff33ab2a52cce9c82fd2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20B=C5=82aszczyk?= <105283001+piotrblaszczyk@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:49:20 +0200 Subject: [PATCH 12/14] feat(sdk): reduce required peer dependencies (#69) * feat(sdk): reduce required peer dependencies * feat(sdk): re-export JsonForms authoring primitives --- .changeset/slim-peer-dependencies.md | 5 + README.md | 9 +- apps/ai-studio/package.json | 2 - apps/demo/package.json | 3 - .../quick-start/wb-as-react-component.mdx | 13 +- .../content/docs/get-started/side-effects.md | 2 +- .../content/docs/guides/add-a-custom-node.mdx | 15 +- .../docs/guides/custom-jsonforms-control.md | 31 +-- apps/docs/src/landing-pages/api-index.md | 2 +- packages/sdk/README.md | 42 +++-- packages/sdk/package.json | 18 +- .../sdk/src/features/json-form/authoring.ts | 177 ++++++++++++++++++ packages/sdk/src/index.ts | 8 + packages/sdk/vite.config.mts | 25 ++- pnpm-lock.yaml | 80 +++----- 15 files changed, 291 insertions(+), 141 deletions(-) create mode 100644 .changeset/slim-peer-dependencies.md create mode 100644 packages/sdk/src/features/json-form/authoring.ts diff --git a/.changeset/slim-peer-dependencies.md b/.changeset/slim-peer-dependencies.md new file mode 100644 index 000000000..9e1f3f21d --- /dev/null +++ b/.changeset/slim-peer-dependencies.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Slimmer install: the base install is now `@workflowbuilder/sdk @xyflow/react zustand`. `@jsonforms/core`, `@jsonforms/react`, `i18next`, `react-i18next`, `i18next-browser-languagedetector` and `immer` moved from peer to regular dependencies and install automatically. JsonForms authoring primitives (`withJsonFormsControlProps`, `rankWith`, `useJsonForms`, `RuleEffect`, `ControlProps`, …) are now re-exported from `@workflowbuilder/sdk`, so custom renderers need no extra installs. diff --git a/README.md b/README.md index 3b8ad4e2e..c8aa662ff 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,11 @@ Don't want to install or clone anything yet? [Try the live demo](https://app.wor Use Workflow Builder inside your own React app. No clone, no Docker. Install the SDK and its peer dependencies from npm: ```bash -npm install @workflowbuilder/sdk \ - react react-dom \ - @xyflow/react \ - @jsonforms/core @jsonforms/react \ - i18next react-i18next i18next-browser-languagedetector \ - immer zustand +npm install @workflowbuilder/sdk @xyflow/react zustand ``` +Requires React 18 or 19. + Render the editor: ```tsx diff --git a/apps/ai-studio/package.json b/apps/ai-studio/package.json index 243eec574..747ed5079 100644 --- a/apps/ai-studio/package.json +++ b/apps/ai-studio/package.json @@ -13,8 +13,6 @@ "test:watch": "vitest --passWithNoTests" }, "dependencies": { - "@jsonforms/core": "^3.4.1", - "@jsonforms/react": "^3.4.1", "@phosphor-icons/react": "^2.1.7", "@synergycodes/overflow-ui": "1.0.0-beta.27", "@workflow-builder/types": "workspace:*", diff --git a/apps/demo/package.json b/apps/demo/package.json index db77ad2cd..4869d045b 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -16,8 +16,6 @@ "test:watch": "vitest" }, "dependencies": { - "@jsonforms/core": "^3.4.1", - "@jsonforms/react": "^3.4.1", "@microsoft/clarity": "^1.0.0", "@phosphor-icons/react": "^2.1.7", "@synergycodes/overflow-ui": "1.0.0-beta.27", @@ -27,7 +25,6 @@ "elkjs": "^0.9.3", "html-to-image": "1.11.11", "i18next": "^24.2.3", - "i18next-browser-languagedetector": "^8.0.5", "immer": "^10.1.1", "jspdf": "^3.0.1", "libavoid-js": "0.4.0-beta.1", diff --git a/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx b/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx index cbdd336c7..6fe69ef3b 100644 --- a/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx +++ b/apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx @@ -27,31 +27,28 @@ Install the SDK along with its peer dependencies: ```bash -npm install @workflowbuilder/sdk react react-dom @xyflow/react @jsonforms/core @jsonforms/react i18next react-i18next i18next-browser-languagedetector immer zustand +npm install @workflowbuilder/sdk @xyflow/react zustand ``` ```bash -pnpm add @workflowbuilder/sdk react react-dom @xyflow/react @jsonforms/core @jsonforms/react i18next react-i18next i18next-browser-languagedetector immer zustand +pnpm add @workflowbuilder/sdk @xyflow/react zustand ``` ```bash -yarn add @workflowbuilder/sdk react react-dom @xyflow/react @jsonforms/core @jsonforms/react i18next react-i18next i18next-browser-languagedetector immer zustand +yarn add @workflowbuilder/sdk @xyflow/react zustand ``` -The SDK ships its non-peer dependencies bundled inside `dist/`, so the -peer list above is everything you need to install yourself. React, -xyflow, JsonForms, i18next, immer and zustand are kept external because -they expose singletons (store identity, i18next instance, frozen-object -caches) — your app and the SDK must share a single copy of each. +Requires React 18 or 19. Everything else the SDK uses (JsonForms, +i18next, immer, …) is a regular dependency and installs automatically. ## Usage diff --git a/apps/docs/src/content/docs/get-started/side-effects.md b/apps/docs/src/content/docs/get-started/side-effects.md index ea8f686a8..dfaa61b8b 100644 --- a/apps/docs/src/content/docs/get-started/side-effects.md +++ b/apps/docs/src/content/docs/get-started/side-effects.md @@ -9,7 +9,7 @@ Importing `@workflowbuilder/sdk` runs a handful of module-level side effects on ## Side effects on import -- **`immer`** — calls `setAutoFreeze(false)`. ReactFlow mutates the objects produced by the SDK's `produce` calls (size, position, internal flags), so the SDK's drafts must not be auto-frozen. Because `immer` is a singleton peer, this disables auto-freeze **globally** for the host app — any of your own reducers, RTK slices, or libraries that rely on frozen drafts lose that protection. If you have your own immer flows that depend on frozen drafts, treat it as a known caveat. +- **`immer`** — calls `setAutoFreeze(false)`. ReactFlow mutates the objects produced by the SDK's `produce` calls (size, position, internal flags), so the SDK's drafts must not be auto-frozen. Because `immer` is a shared, deduped dependency, this disables auto-freeze **globally** for the host app — any of your own reducers, RTK slices, or libraries that rely on frozen drafts lose that protection. If you have your own immer flows that depend on frozen drafts, treat it as a known caveat. - **`i18next`** — initialises the i18next instance with `react-i18next`, the language detector, and the SDK's bundled `en` / `pl` translations. If your app already configured i18next before importing the SDK, the SDK's `i18n.init(...)` is a no-op for the second `init` per i18next's contract — the registry is shared. ## Known limitations diff --git a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx index 8f3b34d38..8dd8d34e9 100644 --- a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx +++ b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx @@ -151,16 +151,9 @@ To render a property with a custom React component, split the renderer into two `renderers/color-picker.tsx`: ```tsx -import { withJsonFormsControlProps } from '@jsonforms/react'; +import { type ControlProps, withJsonFormsControlProps } from '@workflowbuilder/sdk'; -type Props = { - data: string; - handleChange: (path: string, value: string) => void; - path: string; - label?: string; -}; - -function ColorPickerControl({ data, handleChange, path, label }: Props) { +function ColorPickerControl({ data, handleChange, path, label }: ControlProps) { return (