diff --git a/.claude/skills/package-security-check/SKILL.md b/.claude/skills/package-security-check/SKILL.md new file mode 100644 index 0000000..d197015 --- /dev/null +++ b/.claude/skills/package-security-check/SKILL.md @@ -0,0 +1,62 @@ +--- +name: package-security-check +description: Check whether a package is secure before installing or adding it as a dependency. Use this skill EVERY time you are about to install a package or add a dependency — e.g. npm install, npm i, yarn add, pnpm add, pip install, adding entries to package.json or requirements.txt — or whenever the user asks whether a package is safe, secure, trustworthy, vulnerable, or asks for a security check/score of a package. Trigger even if the user doesn't mention security, because installing any third-party package should be preceded by this check. +--- + +# Package Security Check + +Before installing any third-party package, verify it has no known vulnerabilities and a reasonable security health score. If it's clean, proceed with the installation. If not, STOP and show the user a warning, then let them decide. + +## Workflow + +1. **Run the check** before executing any install command: + + ```bash + python3 scripts/check_package.py [version] [--ecosystem npm] + ``` + + - If the user specified a version (e.g. `lodash@4.17.20`), pass it. Otherwise the script checks the latest version. + - Ecosystem defaults to `npm`. Also supports `PyPI`, `Go`, `Maven`, `NuGet`, `RubyGems`, `crates.io`. + - When installing multiple packages, check each one. + +2. **Act on the exit code:** + + - **Exit 0 (SECURE)** — proceed with the installation without asking. Briefly mention the check passed, e.g. "✓ express@4.19.2 — no known vulnerabilities, Scorecard 7.6/10. Installing…" + - **Exit 1 (WARNING)** — do NOT install yet. Show the user a clear warning and ask whether to proceed, pick a different version, or choose an alternative package. See the warning format below. + - **Exit 2 (ERROR)** — the check itself failed (network, package not found). Tell the user the check could not be completed and ask whether to proceed unverified. Never silently install after a failed check. + +3. **If the user explicitly says to skip the check or install anyway**, respect that — this skill informs, it doesn't block. + +## What the script checks + +- **Known vulnerabilities** via OSV.dev — authoritative CVE/GHSA data for the exact version. Any hit triggers a warning. +- **OpenSSF Scorecard** via deps.dev — a 0–10 health heuristic (code review, maintenance, branch protection, etc.). Scores below 4.0 trigger a warning. This is a signal, not proof of insecurity — say so when warning about score alone. +- A missing Scorecard (no public GitHub repo, or repo not scanned) is common and NOT by itself a reason to warn. + +## Warning format + +When the verdict is WARNING, present it like this before asking how to proceed: + +``` +⚠️ Security warning: @ + +Known vulnerabilities: +- GHSA-xxxx [HIGH] Prototype pollution in ... + (fixed in , if the script output mentions one) + +OpenSSF Scorecard: 2.9/10 (low — weak maintenance/review signals) + +Options: +1. Install a patched version (recommended if one exists) +2. Pick an alternative package +3. Install anyway +``` + +When a vulnerability is fixed in a newer version, recommend installing that version as the default suggestion. + +## Notes & edge cases + +- Scoped npm packages work as-is (`@nestjs/core`) — the script handles URL-encoding. +- For version *ranges* in package.json, check the version that would actually resolve (run `npm view version` to find it if unsure). +- This check covers known vulnerabilities and project health. It does NOT detect zero-day malware or typosquatting — if a package name looks like a misspelling of a popular package (e.g. `expresss`, `lodahs`), flag that to the user separately. +- The APIs used (api.osv.dev, api.deps.dev) are free and require no authentication. If the environment blocks these domains, report exit code 2 behavior. \ No newline at end of file diff --git a/.claude/skills/package-security-check/check_package.py b/.claude/skills/package-security-check/check_package.py new file mode 100644 index 0000000..b860106 --- /dev/null +++ b/.claude/skills/package-security-check/check_package.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +check_package.py - Check whether a package is safe to install. + +Data sources (all free, no API key): + 1. OSV.dev -> known vulnerabilities (ground truth: CVEs/GHSAs) + 2. deps.dev -> package metadata + OpenSSF Scorecard (health score 0-10) + +Usage: + python3 check_package.py [version] [--ecosystem npm] + +Examples: + python3 check_package.py lodash 4.17.20 + python3 check_package.py express + python3 check_package.py requests 2.19.0 --ecosystem PyPI + +Exit codes: + 0 = SECURE (no known vulnerabilities, acceptable health score) + 1 = WARNING (known vulnerabilities and/or low health score) + 2 = ERROR (network failure, package not found, etc.) +""" + +import argparse +import json +import sys +import urllib.parse +import urllib.request + +OSV_API = "https://api.osv.dev/v1/query" +DEPSDEV_API = "https://api.deps.dev/v3" +SCORECARD_WARN_THRESHOLD = 4.0 # warn if OpenSSF score is below this +TIMEOUT = 15 + + +def http_json(url, payload=None): + req = urllib.request.Request( + url, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Content-Type": "application/json", + "User-Agent": "package-security-check"}, + method="POST" if payload is not None else "GET", + ) + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode()) + + +def get_latest_version(name, ecosystem): + """Resolve the default/latest version from deps.dev.""" + url = f"{DEPSDEV_API}/systems/{ecosystem}/packages/{urllib.parse.quote(name, safe='')}" + data = http_json(url) + for v in data.get("versions", []): + if v.get("isDefault"): + return v["versionKey"]["version"] + versions = data.get("versions", []) + return versions[-1]["versionKey"]["version"] if versions else None + + +def query_osv(name, version, ecosystem): + """Return list of known vulnerabilities for this package version.""" + payload = {"package": {"name": name, "ecosystem": ecosystem}} + if version: + payload["version"] = version + data = http_json(OSV_API, payload) + return data.get("vulns", []) + + +def query_scorecard(name, version, ecosystem): + """Return (score, repo) from deps.dev's OpenSSF Scorecard data, or (None, None).""" + url = ( + f"{DEPSDEV_API}/systems/{ecosystem}/packages/" + f"{urllib.parse.quote(name, safe='')}/versions/{urllib.parse.quote(version, safe='')}" + ) + data = http_json(url) + for project in data.get("relatedProjects", []): + project_key = project.get("projectKey", {}).get("id") + if not project_key: + continue + try: + pdata = http_json( + f"{DEPSDEV_API}/projects/{urllib.parse.quote(project_key, safe='')}") + except Exception: + continue + scorecard = pdata.get("scorecard") + if scorecard and "overallScore" in scorecard: + return scorecard["overallScore"], project_key + return None, None + + +def severity_label(vuln): + """Best-effort severity extraction from an OSV record.""" + sev = vuln.get("database_specific", {}).get("severity") + if sev: + return sev.upper() + for s in vuln.get("severity", []): + if s.get("type", "").startswith("CVSS"): + return f"CVSS {s.get('score', '?')}" + return "UNKNOWN" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("package") + parser.add_argument("version", nargs="?", default=None) + parser.add_argument("--ecosystem", default="npm", + help="npm (default), PyPI, Go, Maven, NuGet, RubyGems, crates.io") + parser.add_argument("--json", action="store_true", + help="machine-readable output") + args = parser.parse_args() + + name, version, eco = args.package, args.version, args.ecosystem + result = {"package": name, "ecosystem": eco, "version": version, + "vulnerabilities": [], "scorecard": None, "verdict": None, "notes": []} + + # Resolve version if not given (needed for precise OSV + scorecard lookup) + if not version: + try: + version = get_latest_version(name, eco) + result["version"] = version + result["notes"].append( + f"No version specified; checked latest ({version}).") + except Exception as e: + result["notes"].append(f"Could not resolve latest version: {e}") + + # 1. Known vulnerabilities (OSV) — this is the authoritative check + try: + vulns = query_osv(name, version, eco) + for v in vulns: + result["vulnerabilities"].append({ + "id": v.get("id"), + "summary": (v.get("summary") or v.get("details", ""))[:140], + "severity": severity_label(v), + }) + except Exception as e: + result["notes"].append(f"OSV query failed: {e}") + result["verdict"] = "ERROR" + + # 2. Health score (OpenSSF Scorecard via deps.dev) — heuristic signal + if version: + try: + score, repo = query_scorecard(name, version, eco) + if score is not None: + result["scorecard"] = {"score": score, "repo": repo} + else: + result["notes"].append( + "No OpenSSF Scorecard available (no scored GitHub repo).") + except Exception as e: + result["notes"].append(f"Scorecard lookup failed: {e}") + + # Verdict + if result["verdict"] != "ERROR": + vuln_count = len(result["vulnerabilities"]) + low_score = (result["scorecard"] is not None + and result["scorecard"]["score"] < SCORECARD_WARN_THRESHOLD) + result["verdict"] = "WARNING" if ( + vuln_count or low_score) else "SECURE" + + # Output + if args.json: + print(json.dumps(result, indent=2)) + else: + v = result["verdict"] + icon = {"SECURE": "[OK]", "WARNING": "[!!]", "ERROR": "[??]"}[v] + print(f"{icon} {v}: {name}@{result['version'] or '?'} ({eco})") + if result["vulnerabilities"]: + print( + f"\nKnown vulnerabilities ({len(result['vulnerabilities'])}):") + for vu in result["vulnerabilities"]: + print(f" - {vu['id']} [{vu['severity']}] {vu['summary']}") + if result["scorecard"]: + sc = result["scorecard"] + flag = " (below threshold!)" if sc["score"] < SCORECARD_WARN_THRESHOLD else "" + print( + f"\nOpenSSF Scorecard: {sc['score']}/10{flag} repo: {sc['repo']}") + for note in result["notes"]: + print(f"note: {note}") + + sys.exit({"SECURE": 0, "WARNING": 1, "ERROR": 2}[result["verdict"]]) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/react-doctor/SKILL.md b/.claude/skills/react-doctor/SKILL.md new file mode 100644 index 0000000..c917efe --- /dev/null +++ b/.claude/skills/react-doctor/SKILL.md @@ -0,0 +1,50 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. +version: "1.2.0" +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx react-doctor@latest --verbose --diff` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx react-doctor@latest --verbose` (without `--diff`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## /doctor — full local triage workflow + +When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: + +```bash +curl --fail --silent --show-error \ + --header 'Cache-Control: no-cache' \ + https://www.react.doctor/prompts/react-doctor-agent.md +``` + +The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. + +Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. + +## Configuring or explaining rules + +When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). + +## Command + +```bash +npx react-doctor@latest --verbose --diff +``` + +| Flag | Purpose | +| ----------- | --------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--diff` | Only scan changed files vs base branch | +| `--score` | Output only the numeric score | diff --git a/.claude/skills/react-doctor/references/explain.md b/.claude/skills/react-doctor/references/explain.md new file mode 100644 index 0000000..8e4defe --- /dev/null +++ b/.claude/skills/react-doctor/references/explain.md @@ -0,0 +1,72 @@ +# Explaining and configuring rules + +Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user +wants to understand a rule or change which rules run — not for fixing diagnostics +(that is the main `react-doctor` skill / `/doctor`). + +Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", +"stop flagging X", "too noisy", "disable design rules". + +## Workflow + +1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). +2. Explain it before changing anything: + +```bash +npx react-doctor@latest rules explain react-doctor/no-array-index-as-key +``` + +3. Pick the narrowest control that matches the user's intent (see decision guide). +4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). +5. Validate the change did what they wanted: + +```bash +npx react-doctor@latest --verbose --diff +``` + +## Commands + +```bash +npx react-doctor@latest rules list # every rule + its effective severity +npx react-doctor@latest rules list --configured # only what your config changed +npx react-doctor@latest rules list --category Performance # filter by category +npx react-doctor@latest rules explain # why it matters + how to configure +npx react-doctor@latest rules disable # rule never runs +npx react-doctor@latest rules enable # turn back on at its recommended severity +npx react-doctor@latest rules set warn # off | warn | error +npx react-doctor@latest rules category "React Native" off # whole category +npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) +npx react-doctor@latest rules unignore-tag design +``` + +Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`). + +## Decision guide + +Match the control to the intent — prefer the narrowest one: + +- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". +- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. +- **A disabled-by-default rule they want on** → `rules enable `. +- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. +- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. +- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. + +How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. + +## Config shape + +Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: + +```ts +// doctor.config.ts +export default { + rules: { "react-doctor/no-array-index-as-key": "off" }, + categories: { "React Native": "warn" }, + ignore: { tags: ["design"] }, +}; +``` + +## Educating the user + +When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..b525d35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + @AGENTS.md + +# Architecture Overview + +**Next-Flow** (branded "Magica") is a visual workflow builder where users design AI-powered pipelines using a drag-and-drop node graph. Workflows are DAGs executed server-side via Trigger.dev, with real-time status polling on the client. + +## Core Data Flow + +1. User builds a workflow (ReactFlow canvas) → saves nodes/edges to DB (`PUT /api/workflows/[id]`) +2. User triggers execution → `POST /api/workflows/[id]/run` creates a `WorkflowRun` + `NodeRunResult` records, dispatches `execute-workflow` to Trigger.dev +3. Trigger.dev topologically sorts the DAG, executes nodes in parallel batches (respecting dependencies), updates `NodeRunResult` per node +4. Frontend polls `GET /api/workflows/[id]/runs/[runId]` every 2s, updates Zustand execution store → UI reflects real-time status + +## Data Model (prisma/schema.prisma) + +- **User** — synced from Clerk via webhook; has `clerkId` (unique), owns `Workflow[]` +- **Workflow** — `nodes` (JSON), `edges` (JSON), belongs to User (cascade delete) +- **WorkflowRun** — status (PENDING/RUNNING/SUCCESS/FAILED/PARTIAL), scope (FULL/PARTIAL/SINGLE), `selectedNodeIds`, `duration` (ms) +- **NodeRunResult** — per-node execution record within a run; has `nodeType` (REQUEST_INPUTS/CROP_IMAGE/GEMINI/RESPONSE), `status`, `input`/`output` (JSON), `error`, `duration` + +## Node Types (components/nodes/) + +| Node Type | File | Purpose | Handles | +|---|---|---|---| +| REQUEST_INPUTS | `RequestInputsNode.tsx` | Source node — user-defined input fields (text/image) | Output only | +| CROP_IMAGE | `CropImageNode.tsx` | Image cropping (percentage-based x/y/w/h) | Input + Output | +| GEMINI | `GeminiNode.tsx` | Google AI (prompt, system prompt, model, temperature) | Input + Output | +| RESPONSE | `ResponseNode.tsx` | Sink node — aggregates upstream results | Input only | + +## Zustand Stores (lib/stores/) + +- **useCanvasStore** — ReactFlow nodes/edges, viewport, selection, full undo/redo (snapshots via `structuredClone`). Key methods: `addNode`, `updateNodeData`, `updateNodeConfig`, `loadWorkflow`, `undo`, `redo` +- **useExecutionStore** — active run tracking: `nodeStatuses`, `nodeResults`, `nodeErrors`, `isRunning`. Methods: `startRun`, `updateNodeStatus`, `setNodeResult`, `completeRun` +- **useHistoryStore** — run history sidebar: `runs`, `selectedRunId`, `selectedRunResults`, `toggleNodeDetail` + +## Trigger.dev Tasks (trigger/tasks.ts) + +- **execute-workflow** — Main orchestrator. Topological sort → parallel batch execution → status updates. This is the entry point for all workflow runs. +- **crop-image** — Image cropping (currently mocked with 30s delay) +- **gemini** — Calls Google Generative AI; fetches images, converts to base64, sends multi-modal prompt + +Config: `trigger.config.ts` — 3600s max duration, 3 retries with exponential backoff. + +## API Routes + +``` +GET/POST /api/workflows # List / Create +GET/PUT/DELETE /api/workflows/[id] # Read / Save / Delete +PATCH /api/workflows/[id]/rename # Rename +POST /api/workflows/[id]/run # Execute (triggers Trigger.dev) +GET /api/workflows/[id]/runs # Run history +GET /api/workflows/[id]/runs/[runId] # Run details + node results +GET /api/workflows/[id]/export # Export as JSON +POST /api/workflows/import # Import from JSON +POST /api/webhook/clerk # Clerk user sync (Svix-verified) +``` + +All workflow routes use `getUserIdFromClerk()` from `lib/auth.ts` — returns 401 if unauthenticated. + +## Key Files + +- `lib/env.ts` — `@t3-oss/env-nextjs` validation for `DATABASE_URL`, `CLERK_SECRET_KEY`, `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` (required) plus optional `GOOGLE_AI_API_KEY`, `TRIGGER_PROJECT_ID`, `CLERK_WEBHOOK_SECRET` +- `lib/auth.ts` — `getUserIdFromClerk()` and `errorResponse()` helpers for API routes +- `lib/workflow.ts` — `getWorkflowWithStatus()` and `listWorkflowsWithStatus()` DB queries +- `lib/default-workflow.ts` — Template nodes (REQUEST_INPUTS + RESPONSE) for new workflows +- `lib/schemas/workflow.ts` — Zod schemas for node, edge, workflow validation +- `lib/engine/executor.ts` — Client-side execution logic +- `components/NodePropertiesPanel.tsx` — Right sidebar for editing selected node config +- `components/app-sidebar.tsx` — Dashboard navigation sidebar +- `hooks/use-mobile.ts` — Mobile detection hook + +## Key Patterns + +- **DAG execution uses in-degree topological sort** — nodes at the same depth run in parallel +- **Undo/redo** — Canvas store snapshots full state before each mutation into `past[]`/`future[]` +- **Polling, not WebSockets** — Execution status updates via 2s interval polling +- **Clerk → DB sync** — Webhook upserts User record; API routes resolve `clerkId` → internal `userId` +- **Default new workflow** — Every new workflow starts with REQUEST_INPUTS and RESPONSE nodes connected diff --git a/app/api/workflows/[id]/route.ts b/app/api/workflows/[id]/route.ts index 89e2c85..d327270 100644 --- a/app/api/workflows/[id]/route.ts +++ b/app/api/workflows/[id]/route.ts @@ -1,10 +1,6 @@ -import type { Prisma } from "@/db"; import { prisma } from "@/db"; import { errorResponse, getUserIdFromClerk } from "@/lib/auth"; -import { - workflowRenameSchema, - workflowUpdateSchema, -} from "@/lib/schemas/workflow"; +import { workflowUpdateSchema } from "@/lib/schemas/workflow"; import { getWorkflowWithStatus } from "@/lib/workflow"; export async function GET( diff --git a/app/api/workflows/import/route.ts b/app/api/workflows/import/route.ts index da8c55b..0acb32c 100644 --- a/app/api/workflows/import/route.ts +++ b/app/api/workflows/import/route.ts @@ -1,6 +1,5 @@ import { prisma } from "@/db"; import { errorResponse, getUserIdFromClerk } from "@/lib/auth"; -import { createDefaultNodes } from "@/lib/default-workflow"; import { workflowImportSchema } from "@/lib/schemas/workflow"; export async function POST(req: Request) { diff --git a/app/api/workflows/route.ts b/app/api/workflows/route.ts index 90a36f8..0a17bf9 100644 --- a/app/api/workflows/route.ts +++ b/app/api/workflows/route.ts @@ -28,11 +28,78 @@ export async function POST(req: Request) { try { const userId = await getUserIdFromClerk(); const body = workflowCreateSchema.parse(await req.json()); - const { nodes, edges } = createDefaultNodes(); + + let nodes: any[]; + let edges: any[]; + let name = body.name ?? "Untitled Workflow"; + + if (body.template === "racing-car") { + name = "AI Racing Car Generator Copy"; + nodes = [ + { + id: "request-inputs-1", + type: "REQUEST_INPUTS", + position: { x: 100, y: 200 }, + data: { + label: "Request Inputs", + config: { + fields: [{ name: "Car prompt", type: "text", value: "" }], + }, + }, + deletable: false, + selected: false, + }, + { + id: "gemini-1", + type: "GEMINI", + position: { x: 350, y: 200 }, + data: { + label: "Gemini", + config: { + model: "gemini-2.0-flash", + prompt: + "Generate a detailed image prompt or description for a racing car based on: {{Car prompt}}", + temperature: 0.7, + }, + }, + selected: false, + }, + { + id: "response-1", + type: "RESPONSE", + position: { x: 600, y: 200 }, + data: { + label: "Response", + config: {}, + }, + deletable: false, + selected: false, + }, + ]; + edges = [ + { + id: "edge-1", + source: "request-inputs-1", + target: "gemini-1", + sourceHandle: "Car prompt", + animated: true, + }, + { + id: "edge-2", + source: "gemini-1", + target: "response-1", + animated: true, + }, + ]; + } else { + const defaultWf = createDefaultNodes(); + nodes = defaultWf.nodes; + edges = defaultWf.edges; + } const workflow = await prisma.workflow.create({ data: { - name: body.name ?? "Untitled Workflow", + name, userId, nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)), diff --git a/app/app/flow/page.tsx b/app/app/flow/page.tsx new file mode 100644 index 0000000..3d794c6 --- /dev/null +++ b/app/app/flow/page.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useUser } from "@clerk/nextjs"; +import { + FileUp, + MoreHorizontal, + Pencil, + Plus, + Search, + Trash2, + Upload, + Workflow as WorkflowIcon, +} from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface Workflow { + id: string; + name: string; + isRunning: boolean; + createdAt: string; + updatedAt: string; +} + +function timeAgo(date: string) { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export default function FlowPage() { + const { isLoaded, isSignedIn } = useUser(); + const router = useRouter(); + const [workflows, setWorkflows] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + + const fetchWorkflows = useCallback(async () => { + try { + const res = await fetch("/api/workflows"); + if (res.ok) setWorkflows(await res.json()); + } catch (err) { + console.log("[FLOW] fetch error:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (isLoaded && !isSignedIn) { + router.push("/"); + return; + } + if (isLoaded) fetchWorkflows(); + }, [isLoaded, isSignedIn, router, fetchWorkflows]); + + const createWorkflow = async (template?: string) => { + const res = await fetch("/api/workflows", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(template ? { template } : {}), + }); + if (res.ok) { + const wf = await res.json(); + router.push(`/app/workflows/${wf.id}`); + } + }; + + const deleteWorkflow = async (id: string) => { + await fetch(`/api/workflows/${id}`, { method: "DELETE" }); + setWorkflows((prev) => prev.filter((w) => w.id !== id)); + setDeleteTarget(null); + }; + + const renameWorkflow = async (id: string) => { + const name = renameValue.trim(); + if (!name) return; + await fetch(`/api/workflows/${id}/rename`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + setWorkflows((prev) => prev.map((w) => (w.id === id ? { ...w, name } : w))); + setRenaming(null); + }; + + const importWorkflow = async () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const text = await file.text(); + const res = await fetch("/api/workflows/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: text, + }); + if (res.ok) { + const wf = await res.json(); + router.push(`/app/workflows/${wf.id}`); + } + }; + input.click(); + }; + + const filtered = workflows.filter((w) => + w.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + if (!isLoaded || loading) { + return ( +
+ + + +
+ {Array.from({ length: 3 }, (_, i) => ( + // biome-ignore lint: static skeleton + + ))} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Flow +

+

+ Build workflows or run models directly +

+
+
+ + +
+
+ + {/* System Workflows */} +
+

+ System Workflows +

+

+ Prebuilt workflow templates - click to open and start using. +

+ +
+ {/* System template card */} + +
+
+ + {/* Your Workflows */} +
+
+

+ Your Workflows +

+
+ + setSearchQuery(e.target.value)} + className="pl-10.5 h-11 w-64 text-[14.5px] border-gray-200 rounded-xl" + /> +
+
+

+ Open one to edit, run, and review history. +

+ + {filtered.length === 0 && workflows.length === 0 ? ( +
+
+ +
+

+ No workflows yet +

+

+ Create your first workflow to get started +

+ +
+ ) : filtered.length === 0 ? ( +

+ No workflows match "{searchQuery}" +

+ ) : ( +
+ {filtered.map((wf) => ( +
+ {/* Card image container */} +
+ {wf.name.toLowerCase().includes("racing car") ? ( + {wf.name} router.push(`/app/workflows/${wf.id}`)} + /> + ) : ( + + )} + {wf.isRunning && ( +
+ + + + + + Running + +
+ )} +
+ + {/* Card content */} +
+
+ {renaming === wf.id ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") renameWorkflow(wf.id); + if (e.key === "Escape") setRenaming(null); + }} + onBlur={() => renameWorkflow(wf.id)} + /> + ) : ( + <> + + + Edited {timeAgo(wf.updatedAt)} + + + )} +
+ + + + + + + router.push(`/app/workflows/${wf.id}`)} + > + + Open + + { + setRenaming(wf.id); + setRenameValue(wf.name); + }} + > + + Rename + + + setDeleteTarget(wf)} + > + + Delete + + + +
+
+ ))} +
+ )} +
+ + {/* Delete Confirmation Dialog */} + { + if (!open) setDeleteTarget(null); + }} + > + {deleteTarget && ( + + + Delete workflow + + Are you sure you want to delete "{deleteTarget.name}"? + This action cannot be undone. + + + + + + + + )} + +
+ ); +} + +function _Share2Icon({ className }: { className?: string }) { + return ( + + Flow + + + ); +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 0000000..434cc20 --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,17 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { PromoBanner } from "@/components/promo-banner"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; + +export default function AppLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
+ + + + {children} + +
+ ); +} diff --git a/app/app/workflows/[id]/page.tsx b/app/app/workflows/[id]/page.tsx new file mode 100644 index 0000000..ddf9898 --- /dev/null +++ b/app/app/workflows/[id]/page.tsx @@ -0,0 +1,1490 @@ +"use client"; + +import { + Background, + BackgroundVariant, + type Connection, + Controls, + MiniMap, + type Node, + ReactFlow, + ReactFlowProvider, + useReactFlow, +} from "@xyflow/react"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import "@xyflow/react/dist/style.css"; +import { + AlignLeft, + ArrowLeft, + Check, + Copy, + Download, + History, + Loader2, + Pencil, + Play, + Plus, + Redo2, + Save, + Search, + Sparkles, + Terminal, + Undo2, + Upload, +} from "lucide-react"; +import NodePropertiesPanel from "@/components/NodePropertiesPanel"; +import CropImageNode from "@/components/nodes/CropImageNode"; +import GeminiNode from "@/components/nodes/GeminiNode"; +import RequestInputsNode from "@/components/nodes/RequestInputsNode"; +import ResponseNode from "@/components/nodes/ResponseNode"; +import { Button } from "@/components/ui/button"; +import { + useCanvasStore, + useExecutionStore, + useHistoryStore, +} from "@/lib/stores"; + +const nodeTypes = { + REQUEST_INPUTS: RequestInputsNode, + RESPONSE: ResponseNode, + CROP_IMAGE: CropImageNode, + GEMINI: GeminiNode, +}; + +function WorkflowDetailsInner() { + const params = useParams(); + const router = useRouter(); + const workflowId = params.id as string; + const reactFlowInstance = useReactFlow(); + + // Workflow Page State + const [workflowName, setWorkflowName] = useState("Untitled Workflow"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showPicker, setShowPicker] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [editingNodeId, setEditingNodeId] = useState(null); + const [isEditingWorkflow, setIsEditingWorkflow] = useState(false); + + // Tabs layout states + const [activeTab, setActiveTab] = useState<"playground" | "api" | "workflow">( + "playground", + ); + const [inputValues, setInputValues] = useState>({}); + const [copiedApi, setCopiedApi] = useState(false); + const [copiedOutput, setCopiedOutput] = useState(false); + const [selectedApiLang, setSelectedApiLang] = useState("curl"); + const [historySearchQuery, setHistorySearchQuery] = useState(""); + const [historyTab, setHistoryTab] = useState<"ui" | "api">("ui"); + + // Track UI-triggered runs + const isUiRun = useCallback( + (runId: string) => { + if (typeof window === "undefined") return true; + try { + const uiRuns = JSON.parse( + localStorage.getItem(`ui_runs_${workflowId}`) ?? "[]", + ); + if (uiRuns.length === 0) return true; // Fallback: default to UI runs if list empty + return uiRuns.includes(runId); + } catch { + return true; + } + }, + [workflowId], + ); + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return "—"; + try { + const d = new Date(dateStr); + return d + .toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + .replace(",", ""); + } catch { + return dateStr; + } + }; + + const renderStatusBadge = (status: string) => { + switch (status) { + case "SUCCESS": + return ( + + + Success + + ); + case "FAILED": + return ( + + + Failed + + ); + case "RUNNING": + return ( + + + Running + + ); + case "PENDING": + return ( + + + Pending + + ); + default: + return ( + + + {status.toLowerCase()} + + ); + } + }; + + const getUsedCredits = (run: (typeof runs)[number]) => { + if (run.status === "PENDING" || run.status === "RUNNING") return "—"; + if (run.status === "FAILED") return "0"; + const durationSec = (run.duration ?? 0) / 1000; + return Math.max(1, Math.round(durationSec * 3 + 1)); + }; + + // Stores + const updateNodeData = useCanvasStore((s) => s.updateNodeData); + const updateNodeConfig = useCanvasStore((s) => s.updateNodeConfig); + const autoSaveTimer = useRef>(undefined); + const isSavingRef = useRef(false); + const pollRef = useRef>(undefined); + + const { + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + addNode, + loadWorkflow, + undo, + redo, + } = useCanvasStore(); + const { + isRunning, + startRun, + updateNodeStatus, + setNodeResult, + setNodeError, + completeRun, + } = useExecutionStore(); + const { + runs, + setRuns, + selectedRunId, + selectRun, + selectedRunResults, + setSelectedRunResults, + expandedNodeIds, + toggleNodeDetail, + } = useHistoryStore(); + + const filteredRuns = runs.filter((run) => { + const isUi = isUiRun(run.id); + const tabMatches = historyTab === "ui" ? isUi : !isUi; + const searchMatches = run.id + .toLowerCase() + .includes(historySearchQuery.toLowerCase()); + return tabMatches && searchMatches; + }); + + const loadRunDetails = useCallback( + async (runId: string) => { + const res = await fetch(`/api/workflows/${workflowId}/runs/${runId}`); + if (res.ok) { + const data = await res.json(); + setSelectedRunResults(data.nodeResults ?? []); + } + }, + [workflowId, setSelectedRunResults], + ); + + // Load Workflow Data + useEffect(() => { + async function load() { + try { + const res = await fetch(`/api/workflows/${workflowId}`); + if (!res.ok) { + router.push("/app/flow"); + return; + } + const wf = await res.json(); + setWorkflowName(wf.name); + loadWorkflow(wf.nodes ?? [], wf.edges ?? []); + } catch (err) { + console.log("[CANVAS] load error:", err); + } finally { + setLoading(false); + } + } + load(); + }, [workflowId, loadWorkflow, router]); + + // Load Runs + const fetchRuns = useCallback(async () => { + const res = await fetch(`/api/workflows/${workflowId}/runs`); + if (res.ok) setRuns(await res.json()); + }, [workflowId, setRuns]); + + useEffect(() => { + fetchRuns(); + }, [fetchRuns]); + + // Select latest run details on load + useEffect(() => { + if (runs.length > 0 && !selectedRunId) { + const latest = runs[0]; + selectRun(latest.id); + loadRunDetails(latest.id); + } + }, [runs, selectedRunId, selectRun, loadRunDetails]); + + // Auto-Save + useEffect(() => { + autoSaveTimer.current = setInterval(() => { + if (isSavingRef.current) return; + const { nodes: n, edges: e } = useCanvasStore.getState(); + if (n.length > 0) { + isSavingRef.current = true; + fetch(`/api/workflows/${workflowId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodes: n, edges: e }), + }) + .finally(() => { + isSavingRef.current = false; + }) + .catch(() => {}); + } + }, 10000); + return () => clearInterval(autoSaveTimer.current); + }, [workflowId]); + + // Save Now manually + const saveNow = useCallback(async () => { + if (isSavingRef.current) return; + setSaving(true); + isSavingRef.current = true; + try { + const { nodes: n, edges: e } = useCanvasStore.getState(); + await fetch(`/api/workflows/${workflowId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodes: n, edges: e }), + }); + } finally { + isSavingRef.current = false; + setSaving(false); + } + }, [workflowId]); + + // Run execution logic + const runWorkflow = useCallback(async () => { + const nodeIds = useCanvasStore.getState().nodes.map((n) => n.id); + startRun("", nodeIds); + + try { + const res = await fetch(`/api/workflows/${workflowId}/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + completeRun(); + return; + } + + const run = await res.json(); + if (typeof window !== "undefined") { + try { + const uiRuns = JSON.parse( + localStorage.getItem(`ui_runs_${workflowId}`) ?? "[]", + ); + uiRuns.push(run.id); + localStorage.setItem(`ui_runs_${workflowId}`, JSON.stringify(uiRuns)); + } catch (e) { + console.error("[RUN] LocalStorage tracking error:", e); + } + } + selectRun(run.id); + fetchRuns(); + + pollRef.current = setInterval(async () => { + try { + const statusRes = await fetch( + `/api/workflows/${workflowId}/runs/${run.id}`, + ); + if (!statusRes.ok) { + clearInterval(pollRef.current); + completeRun(); + return; + } + const data = await statusRes.json(); + if (["SUCCESS", "FAILED", "PARTIAL"].includes(data.status)) { + clearInterval(pollRef.current); + console.log( + "[RUN DONE] nodeResults:", + JSON.stringify(data.nodeResults), + ); + for (const nr of data.nodeResults ?? []) { + updateNodeStatus(nr.nodeId, nr.status.toLowerCase()); + if (nr.output != null) { + setNodeResult(nr.nodeId, nr.output); + updateNodeData(nr.nodeId, { lastOutput: nr.output }); + } + if (nr.error != null) { + setNodeError(nr.nodeId, nr.error); + } + } + completeRun(); + fetchRuns(); + loadRunDetails(run.id); + } + } catch { + clearInterval(pollRef.current); + completeRun(); + } + }, 2000); + } catch (err) { + console.log("[CANVAS] run error:", err); + completeRun(); + } + }, [ + workflowId, + startRun, + updateNodeStatus, + setNodeResult, + setNodeError, + completeRun, + selectRun, + fetchRuns, + updateNodeData, + loadRunDetails, + ]); + + const onConnectHandler = useCallback( + (connection: Connection) => onConnect(connection), + [onConnect], + ); + + const onNodeDoubleClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + setEditingNodeId(node.id); + }, + [], + ); + + const addNodeType = useCallback( + (type: string) => { + const pos = reactFlowInstance.screenToFlowPosition({ + x: window.innerWidth / 2 + Math.random() * 100, + y: window.innerHeight / 3 + Math.random() * 100, + }); + addNode({ + id: `${type.toLowerCase().replace(/_/g, "-")}-${Date.now()}`, + type, + position: pos, + data: { config: {} }, + selected: false, + }); + setShowPicker(false); + }, + [reactFlowInstance, addNode], + ); + + const exportWorkflow = useCallback(async () => { + const res = await fetch(`/api/workflows/${workflowId}/export`); + if (res.ok) { + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${workflowName}.json`; + a.click(); + URL.revokeObjectURL(url); + } + }, [workflowId, workflowName]); + + const importWorkflow = useCallback(async () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + const text = await file.text(); + const res = await fetch("/api/workflows/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: text, + }); + if (res.ok) { + const wf = await res.json(); + router.push(`/app/workflows/${wf.id}`); + } + }; + input.click(); + }, [router]); + + // Playground Fields & Value Sync + const requestInputsNode = nodes.find((n) => n.type === "REQUEST_INPUTS"); + const inputFields = (requestInputsNode?.data?.config as any)?.fields ?? []; + + useEffect(() => { + if (inputFields.length > 0) { + const initialVals: Record = {}; + for (const field of inputFields) { + initialVals[field.name] = field.value ?? ""; + } + setInputValues((prev) => ({ ...initialVals, ...prev })); + } + }, [inputFields]); + + const handleInputChange = (name: string, val: string) => { + setInputValues((prev) => ({ ...prev, [name]: val })); + }; + + const handlePlaygroundRun = async () => { + if (requestInputsNode) { + const updatedFields = inputFields.map((f: any) => ({ + ...f, + value: inputValues[f.name] ?? f.value ?? "", + })); + + // Update in state + updateNodeConfig(requestInputsNode.id, { fields: updatedFields }); + + // Save to database directly to ensure trigger execution gets it + const updatedNodes = nodes.map((n) => { + if (n.id === requestInputsNode.id) { + return { + ...n, + data: { + ...n.data, + config: { + ...(n.data.config as Record), + fields: updatedFields, + }, + }, + }; + } + return n; + }); + + try { + await fetch(`/api/workflows/${workflowId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodes: updatedNodes, edges }), + }); + } catch (err) { + console.log("[PLAYGROUND RUN] Save inputs error:", err); + } + } + + // Run execution + await runWorkflow(); + }; + + // Extract Response Node's output + const responseNode = nodes.find((n) => n.type === "RESPONSE"); + const responseResult = selectedRunResults.find( + (r) => r.nodeId === responseNode?.id, + ); + const latestRunResult = responseResult?.output; + const latestRunDuration = runs.find((r) => r.id === selectedRunId)?.duration; + + // Render node outputs beautifully + const renderResponseContent = (result: any) => { + const entries = Object.entries(result); + if (entries.length === 0) { + return ( +
+          {JSON.stringify(result, null, 2)}
+        
+ ); + } + + return ( +
+ {entries.map(([nodeId, value]: [string, any]) => { + const textContent = + value?.response ?? (typeof value === "string" ? value : null); + const imageContent = value?.outputImage ?? null; + + return ( +
+
+ + {nodeId.toUpperCase()} OUTPUT +
+ {imageContent && ( +
+ Output +
+ )} + {textContent ? ( +
+ {textContent} +
+ ) : !imageContent ? ( +
+                  {JSON.stringify(value, null, 2)}
+                
+ ) : null} +
+ ); + })} +
+ ); + }; + + // API code snippets + const curlSnippet = `curl -X POST http://localhost:3000/api/workflows/${workflowId}/run \\ + -H "Content-Type: application/json" \\ + -d '{}'`; + + const jsSnippet = `fetch("http://localhost:3000/api/workflows/${workflowId}/run", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({}) +}) + .then(res => res.json()) + .then(data => console.log("Run triggered successfully:", data));`; + + const pySnippet = `import requests + +url = "http://localhost:3000/api/workflows/${workflowId}/run" +response = requests.post(url, json={}) + +if response.status_code == 201: + print("Run triggered successfully:", response.json()) +else: + print("Execution failed to trigger:", response.status_code, response.text)`; + + const apiSnippets: Record = { + curl: curlSnippet, + javascript: jsSnippet, + python: pySnippet, + }; + + const handleCopyApiSnippet = () => { + navigator.clipboard.writeText(apiSnippets[selectedApiLang]); + setCopiedApi(true); + setTimeout(() => setCopiedApi(false), 2000); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Detail view header matching Screen 1 & 2 */} +
+ {/* Top row */} +
+
+ +

+ {workflowName} +

+ {saving && ( + saving... + )} +
+ + {/* Visual editor controls (only active in Workflow tab) */} + {activeTab === "workflow" && isEditingWorkflow && ( +
+ + +
+ + + +
+ + +
+ )} +
+ + {/* Left-aligned Tab Switcher below title */} +
+ + + +
+
+ + {/* Main content body container */} +
+ {/* Playground Tab */} + {activeTab === "playground" && ( +
+
+ {/* Inputs card layout matching Screen 2 */} +
+
+
+

+ Inputs +

+

+ Configure the input fields for this workflow run +

+
+ + Est. ~1.72 M + +
+ +
+ {inputFields.length === 0 ? ( +
+

+ No fields configured on your Request Inputs node yet. +

+ +
+ ) : ( + inputFields.map((field: any) => ( +
+
+ + + {field.name} + + + {field.type === "image_field" ? "Image" : "Text"} + +
+ {field.type === "image_field" ? ( +
+ + handleInputChange(field.name, e.target.value) + } + placeholder="Enter image URL..." + /> + {inputValues[field.name] && ( +
+ {field.name} +
+ )} +
+ ) : ( +