diff --git a/agent-reactflow/.gitignore b/agent-reactflow/.gitignore new file mode 100644 index 0000000..96fab4f --- /dev/null +++ b/agent-reactflow/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/agent-reactflow/.vscode/settings.json b/agent-reactflow/.vscode/settings.json new file mode 100644 index 0000000..44a73ec --- /dev/null +++ b/agent-reactflow/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ] +} diff --git a/agent-reactflow/Dockerfile b/agent-reactflow/Dockerfile new file mode 100644 index 0000000..606d030 --- /dev/null +++ b/agent-reactflow/Dockerfile @@ -0,0 +1,69 @@ +# ============ +# Base stage +# ============ +FROM node:20-bullseye-slim AS base +# Use the official Node 20 image based on bullseye slim. +# This stage serves as the base image for all subsequent stages. + +# ============ +# Builder stage +# ============ +FROM base AS builder + +WORKDIR /app + +# Install Turbo globally as it is needed during the build. +RUN npm install -g turbo@2.4.4 + +# Copy all application source code. +COPY . . + +# Prune the workspace to only include the necessary scopes. +RUN turbo prune --docker --scope @agent-reactflow/backend + +# ============ +# Installer stage +# ============ +FROM base AS installer + +WORKDIR /app + +# Update the certificate store in this Debian-based stage. +RUN apt-get update && \ + apt-get install -y ca-certificates && \ + update-ca-certificates + +RUN npm install -g pnpm@10.6.1 && \ + npm install -g turbo@2.4.4 + +# Copy pre-pruned dependency files from the builder stage. +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml + +# Install dependencies. +RUN pnpm install + +# Copy the full application and build it. +COPY --from=builder /app/out/full/ . +RUN pnpm build + +# ============ +# Final Runner stage +# ============ +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy the production-ready built application from the installer stage. +COPY --from=installer /app . + +# Install only runtime packages. Here we install curl and ca-certificates, +# then update the certificate store for secure TLS connections. +RUN npm install -g pnpm@10.6.1 && \ + apk add --no-cache curl ca-certificates && update-ca-certificates + +# Start the application using pnpm. +CMD ["pnpm", "start"] + +# # Dummy shell for troubleshooting +# CMD ["sh"] \ No newline at end of file diff --git a/agent-reactflow/README.md b/agent-reactflow/README.md new file mode 100644 index 0000000..a731269 --- /dev/null +++ b/agent-reactflow/README.md @@ -0,0 +1,37 @@ +# Agent with React Flow + +A sample repository to have low code editor with React Flow of Agents built with Restack. + +### Apps + +- `frontend`: another [Next.js](https://nextjs.org/) app +- `backend`: a [Restack](https://restack.io/) app + +## Requirements + +- **Node 20+** +- **pnpm** (recommended) + +## Start Restack + +To start Restack, use the following Docker command: + +```bash +docker run -d --pull always --name restack -p 5233:5233 -p 6233:6233 -p 7233:7233 -p 9233:9233 ghcr.io/restackio/restack:main +``` + +## Install dependencies and start services + +```bash +pnpm i +pnpm run dev +``` + +Leveraging turborepo, this will start both frontend and backend. +Your code will be running and syncing with Restack to execute agents. + +## Run agents + +### from frontend + +![Run agents from frontend](./agent-reactflow.png) \ No newline at end of file diff --git a/agent-reactflow/agent-reactflow.png b/agent-reactflow/agent-reactflow.png new file mode 100644 index 0000000..9be5cbc Binary files /dev/null and b/agent-reactflow/agent-reactflow.png differ diff --git a/agent-reactflow/apps/backend/.env.example b/agent-reactflow/apps/backend/.env.example new file mode 100644 index 0000000..75dbb88 --- /dev/null +++ b/agent-reactflow/apps/backend/.env.example @@ -0,0 +1,10 @@ + +# For inference +OPENAI_API_KEY= + +# For Restack Cloud deployment +RESTACK_ENGINE_ID= +RESTACK_ENGINE_ADDRESS= +RESTACK_ENGINE_API_KEY= +RESTACK_ENGINE_API_ADDRESS= + diff --git a/agent-reactflow/apps/backend/.gitignore b/agent-reactflow/apps/backend/.gitignore new file mode 100644 index 0000000..65e7c43 --- /dev/null +++ b/agent-reactflow/apps/backend/.gitignore @@ -0,0 +1,4 @@ +.env +node_modules +media +dist \ No newline at end of file diff --git a/agent-reactflow/apps/backend/Dockerfile b/agent-reactflow/apps/backend/Dockerfile new file mode 100644 index 0000000..2417164 --- /dev/null +++ b/agent-reactflow/apps/backend/Dockerfile @@ -0,0 +1,40 @@ +# ------- Image ---------- + +FROM node:20-bullseye-slim AS installer + +RUN apt-get update \ + && apt-get install -y ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY ./package.json ./app/package.json +COPY ./tsconfig.json ./app/tsconfig.json + + +WORKDIR /app + +RUN npm install + +# ------- Builder ---------- + +FROM node:20-bullseye-slim AS builder +WORKDIR /app +COPY --from=installer /app . +COPY ./src ./src + +RUN npm run build + +# ------- Runner ---------- + +FROM node:20-bullseye-slim AS runner + +RUN addgroup --system --gid 1001 service +RUN adduser --system --uid 1001 service +USER service + +WORKDIR /app + +COPY --from=builder /app . + +ENV NODE_OPTIONS=”--max-old-space-size=4096″ + +CMD ["node", "dist/services"] \ No newline at end of file diff --git a/agent-reactflow/apps/backend/eventAgent.ts b/agent-reactflow/apps/backend/eventAgent.ts new file mode 100644 index 0000000..4c4cfdf --- /dev/null +++ b/agent-reactflow/apps/backend/eventAgent.ts @@ -0,0 +1,38 @@ +import { client } from "./src/client"; + +export type EventInput = { + agentId: string; + runId: string; +}; + +async function eventAgent(input: EventInput) { + try { + await client.sendAgentEvent({ + event: { + name: "flowEvent", + input: { name: "idVerification", input: { type: "id", documentNumber: "1234567890" } }, + }, + agent: { + agentId: input.agentId, + runId: input.runId, + }, + }); + + // await client.sendAgentEvent({ + // event: { + // name: "end", + // }, + // agent: { + // agentId: input.agentId, + // runId: input.runId, + // }, + // }); + + process.exit(0); // Exit the process successfully + } catch (error) { + console.error("Error sending event to agent:", error); + process.exit(1); // Exit the process with an error code + } +} + +eventAgent({"agentId":"1743008631706-agentFlow","runId":"0195d369-0b9d-7ded-ab82-20e275e295da"}); diff --git a/agent-reactflow/apps/backend/index.html b/agent-reactflow/apps/backend/index.html new file mode 100644 index 0000000..2333578 --- /dev/null +++ b/agent-reactflow/apps/backend/index.html @@ -0,0 +1,289 @@ + + + + + + + + Video Wall + + + + +

Because AI robots deserve holidays too!

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + \ No newline at end of file diff --git a/agent-reactflow/apps/backend/package.json b/agent-reactflow/apps/backend/package.json new file mode 100644 index 0000000..ff8b5b5 --- /dev/null +++ b/agent-reactflow/apps/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "@agent-reactflow/backend", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "start": "tsx src/services.ts", + "start.watch": "nodemon src/services.ts", + "dev": "open-cli http://localhost:5233 && tsx watch --include src src/services.ts", + "build": "tsc --build", + "clean": "rm -rf node_modules", + "workflow": "tsx ./scheduleWorkflow.ts", + "event": "tsx ./eventAgent.ts", + "restack-up": "node restack_up.mjs" + }, + "dependencies": { + "@restackio/ai": "^0.0.115", + "@temporalio/workflow": "1.11.6", + "dotenv": "^16.4.5", + "node-fetch": "^3.3.2", + "openai": "^4.80.1", + "reactflow": "^11.11.4", + "typescript": "^5.8.2", + "ws": "^8.18.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@agent-reactflow/eslint-config": "workspace:*", + "@agent-reactflow/typescript-config": "workspace:*", + "@types/node": "20.16.9", + "@types/ws": "^8.18.0", + "dotenv-cli": "^7.4.2", + "open-cli": "^8.0.0", + "prettier": "3.3.3", + "tsx": "4.19.2" + } +} diff --git a/agent-reactflow/apps/backend/readme.md b/agent-reactflow/apps/backend/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/agent-reactflow/apps/backend/src/agents/chat.ts b/agent-reactflow/apps/backend/src/agents/chat.ts new file mode 100644 index 0000000..2b8f60e --- /dev/null +++ b/agent-reactflow/apps/backend/src/agents/chat.ts @@ -0,0 +1,40 @@ +import { + defineEvent, + onEvent, + condition, + log, + step, + sleep, +} from "@restackio/ai/agent"; +import * as functions from "../functions"; + +const messagesEvent = defineEvent("messages"); +const endEvent = defineEvent("end"); + +type AgentChatOutput = { + messages: functions.Message[]; +}; + +export async function agentChat(): Promise { + let endReceived = false; + let messages: functions.Message[] = []; + + onEvent(messagesEvent, async ({ messages, stream = true }: { messages: functions.Message[], stream?: boolean }) => { + const result = await step({}).llmChat({ + messages, + stream + }); + messages.push(result); + return messages; + }); + + onEvent(endEvent, async () => { + endReceived = true; + }); + + // We use the `condition` function to wait for the event goodbyeReceived to return `True`. + await condition(() => endReceived); + + log.info("end condition met"); + return { messages }; +} diff --git a/agent-reactflow/apps/backend/src/agents/flow.ts b/agent-reactflow/apps/backend/src/agents/flow.ts new file mode 100644 index 0000000..7f59f08 --- /dev/null +++ b/agent-reactflow/apps/backend/src/agents/flow.ts @@ -0,0 +1,133 @@ +import { + defineEvent, + onEvent, + condition, + log, + step, + childExecute, + uuid, + AgentError, + agentInfo, + sleep +} from "@restackio/ai/agent"; +import { Workflow } from "@temporalio/workflow"; +import * as functions from "../functions"; + +export type EndEvent = { + end: boolean; +}; + +export const endEvent = defineEvent("end"); + +export type FlowEvent = { + name: string; + input: any; +}; + +export const flowEvent = defineEvent("flowEvent"); + +export type AgentFlowInput = { + flowJson: any; +} + +export type AgentFlowOutput = { + results: { + id: string; + status: "running" | "completed" | 'error' + input: any; + rawResponse: any; + response: any; + }[]; +} +export async function agentFlow({flowJson}: AgentFlowInput): Promise { + let endReceived = false; + let eventResults: AgentFlowOutput['results'] = [] + + try { + + if (!flowJson) { + + // Mock React Flow JSON to debug with frontend + + flowJson = await step({}).mockFlow(); + } + + const {flowMap} = await step({}).dslInterpreter({ + reactflowJson: flowJson, + }); + + onEvent(flowEvent, async ({ name, input }: FlowEvent) => { + log.info(`Received event: ${name}`); + log.info(`Received event data: ${input}`); + const flow = flowMap.find((flow) => flow.eventName === name); + + if (!flow) { + throw new AgentError(`No workflow found for event: ${name}`); + } + + const childOutput = await childExecute({ + child: flow.workflowType as unknown as Workflow, + childId: uuid(), + input: { + eventData: input, + flow: { + prompt: flow.flowPrompt, + outputConditions: flow.flowOutputConditions, + }, + }, + taskQueue: "workflow", + }); + + eventResults.push({ + id: name, + status: "completed", + input: input, + rawResponse: childOutput.rawResponse, + response: childOutput.response, + }); + + // Evaluate the output against edge conditions + const nextEvent = flow.edgeConditions.find((condition) => { + // Access the correct property within childOutput + const outputCondition = childOutput.response.response; + return outputCondition === condition.condition; + }); + + if (nextEvent) { + + await sleep(1000); + step({}).sendAgentEvent({ + eventName: 'flowEvent', + eventInput: { + name: nextEvent.targetNodeId, + input: childOutput.response, + }, + agentId: agentInfo().workflowId, + runId: agentInfo().runId, + }); + } + return { + ...childOutput, + nextEvent: nextEvent?.targetNodeId, + } + + }); + + onEvent(endEvent, async () => { + await sleep(2000); + endReceived = true; + }); + + // We use the `condition` function to wait for the event goodbyeReceived to return `True`. + await condition(() => endReceived); + + log.info("end condition met"); + return { + results: eventResults, + }; + + } catch (error) { + throw new AgentError("Error in agentFlow", error as string); + } +} + diff --git a/agent-reactflow/apps/backend/src/agents/index.ts b/agent-reactflow/apps/backend/src/agents/index.ts new file mode 100644 index 0000000..ddbd097 --- /dev/null +++ b/agent-reactflow/apps/backend/src/agents/index.ts @@ -0,0 +1,2 @@ +export * from "./flow"; +export * from "./chat"; \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/client.ts b/agent-reactflow/apps/backend/src/client.ts new file mode 100644 index 0000000..95880db --- /dev/null +++ b/agent-reactflow/apps/backend/src/client.ts @@ -0,0 +1,16 @@ +import Restack from "@restackio/ai"; + +import "dotenv/config"; + +export const apiAddress = process.env.RESTACK_ENGINE_API_ADDRESS! + +export const connectionOptions = { + engineId: process.env.RESTACK_ENGINE_ID!, + address: process.env.RESTACK_ENGINE_ADDRESS!, + apiKey: process.env.RESTACK_ENGINE_API_KEY!, + apiAddress, +}; + +export const client = new Restack( + process.env.RESTACK_ENGINE_API_KEY ? connectionOptions : undefined +); diff --git a/agent-reactflow/apps/backend/src/functions/dslInterpreter.ts b/agent-reactflow/apps/backend/src/functions/dslInterpreter.ts new file mode 100644 index 0000000..900bece --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/dslInterpreter.ts @@ -0,0 +1,47 @@ +import { ReactFlowJsonObject } from "reactflow"; + +export function parseReactFlowJson(reactflowJson: ReactFlowJsonObject) { + return { + nodes: reactflowJson.nodes || [], + edges: reactflowJson.edges || [], + }; +} + +export type DSLInterpreterInput = { + reactflowJson: ReactFlowJsonObject; +}; + +export type DSLInterpreterOutput = { + flowMap: { + eventName: string; + workflowType: string; + flowPrompt: string; + flowOutputConditions: string[]; + edgeConditions: { + targetNodeId: string; + condition: string; + }[]; + }[]; +}; + +export async function dslInterpreter({reactflowJson}: DSLInterpreterInput): Promise { + const { nodes, edges } = parseReactFlowJson(reactflowJson); + + const flowMap = nodes.map((node: any) => { + const outgoingEdges = edges.filter((edge: any) => edge.source === node.id); + const edgeConditions = outgoingEdges.map((edge: any) => ({ + targetNodeId: edge.target, + condition: edge.sourceHandle ?? edge.source, + })); + + return { + eventName: node.id, + workflowType: node.id, + flowPrompt: node.data.flowPrompt, + flowOutputConditions: node.data.flowOutputConditions, + edgeConditions, + }; + }); + + return { flowMap }; +} \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/functions/humanVerification.ts b/agent-reactflow/apps/backend/src/functions/humanVerification.ts new file mode 100644 index 0000000..e037c5e --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/humanVerification.ts @@ -0,0 +1,32 @@ +import { FunctionFailure, log } from "@restackio/ai/function"; + + +export type HumanVerificationInput = { + context: string; +}; + +export type HumanVerificationOutput = { + status: "approved" | "declined"; +}; + +export const humanVerification = async ({ + context, +}: HumanVerificationInput): Promise => { + try { + log.info("humanVerification input:", {input: {context}}); + + // Simulate status response + const statuses: HumanVerificationOutput['status'][] = ['approved', 'declined']; + const randomStatusIndex = Math.floor(Math.random() * statuses.length); + const status = statuses[randomStatusIndex]; + + const output: HumanVerificationOutput = { + status, + }; + + log.info(`humanVerification output: ${output}`); + return output; + } catch (error) { + throw FunctionFailure.nonRetryable(`Error humanVerification chat: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/idVerification.ts b/agent-reactflow/apps/backend/src/functions/idVerification.ts new file mode 100644 index 0000000..47e479b --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/idVerification.ts @@ -0,0 +1,77 @@ +import { FunctionFailure, log } from "@restackio/ai/function"; + + +export type IdVerificationInput = { + type: "id" | "passport" | "driverLicense"; + documentNumber: string; +}; + +export type IdVerificationOutput = { + status: "approved" | "declined"; + data: any +}; + +export const idVerification = async ({ + type, + documentNumber, +}: IdVerificationInput): Promise => { + try { + log.info("idVerification input:", {input: {type, documentNumber}}); + + // Simulate status response + const statuses: IdVerificationOutput['status'][] = ['approved', 'declined']; + const randomStatusIndex = Math.floor(Math.random() * statuses.length); + const status = statuses[randomStatusIndex]; + + const output: IdVerificationOutput = { + status, + data: { + birthday: { + status: "ORIGINAL", + original: "1997-05-23" + }, + firstname: { + status: "MATCH", + value: "ERIKA", + original: "ERIKA" + }, + address: { + zipcode: { + status: "ORIGINAL", + original: "W1U" + }, + country: { + status: "ORIGINAL", + original: "DE" + } + }, + birthplace: { + status: "ORIGINAL", + original: "LONDON" + }, + nationality: { + status: "ORIGINAL", + original: "DE" + }, + gender: { + status: "ORIGINAL", + original: "FEMALE" + }, + identlanguage: { + status: "ORIGINAL", + original: "en" + }, + lastname: { + status: "MATCH", + value: "MUSTERMAN", + original: "MUSTERMAN" + } + } + }; + + log.info(`idVerification output: ${output}`); + return output; + } catch (error) { + throw FunctionFailure.nonRetryable(`Error IdVerificationInput chat: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/index.ts b/agent-reactflow/apps/backend/src/functions/index.ts new file mode 100644 index 0000000..2a953c4 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/index.ts @@ -0,0 +1,7 @@ +export * from "./llmResponse"; +export * from "./idVerification"; +export * from "./dslInterpreter"; +export * from "./humanVerification"; +export * from "./mockFlow"; +export * from "./sendAgentEvent"; +export * from "./llmChat"; \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/functions/llmChat.ts b/agent-reactflow/apps/backend/src/functions/llmChat.ts new file mode 100644 index 0000000..e31e3f8 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/llmChat.ts @@ -0,0 +1,117 @@ +import { FunctionFailure, log } from "@restackio/ai/function"; +import { ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, ChatCompletionTool } from "openai/resources/chat/completions"; + +import { openaiClient } from "../utils/client"; +import { apiAddress } from "../client"; +import { streamToWebsocket } from "./stream"; +import z from "zod"; + +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type OpenAIChatInput = { + systemContent?: string; + model?: string; + messages: Message[]; + stream?: boolean; +}; + +export const llmChat = async ({ + systemContent = "", + model = "gpt-4o-mini", + messages, + stream = true, +}: OpenAIChatInput): Promise => { + try { + const openai = openaiClient({}); + + if (stream) { + + const chatParams: ChatCompletionCreateParamsStreaming = { + messages: [ + ...(systemContent + ? [{ role: "system" as const, content: systemContent }] + : []), + ...(messages ?? []), + ], + tools: [ + { + type: "function", + function: { + name: "reactflow", + description: "Update flow", + parameters: { + type: "object", + properties: { + flow: { + type: "object", + description: "The json object of the flow to update" + } + }, + required: [ + "flow" + ], + additionalProperties: false, + }, + }, + }, + ], + model, + stream, + }; + + log.debug("OpenAI chat completion params", { + chatParams, + }); + + const completion = await openai.chat.completions.create(chatParams); + + log.debug("OpenAI chat completion", { + completion, + }); + + + const assistantContent = await streamToWebsocket({ + apiAddress, + data: completion, + }); + + log.debug("Assistant content", { + assistantContent, + }); + + return { + role: "assistant", + content: assistantContent, + }; + + } else { + const chatParams: ChatCompletionCreateParamsNonStreaming = { + messages: [ + ...(systemContent + ? [{ role: "system" as const, content: systemContent }] + : []), + ...(messages ?? []), + ], + model, + }; + + log.debug("OpenAI chat completion params", { + chatParams, + }); + + const completion = await openai.chat.completions.create(chatParams); + + const message = completion.choices[0].message; + + return { + role: message.role, + content: message.content ?? "", + }; + } + } catch (error) { + throw FunctionFailure.nonRetryable(`Error OpenAI chat: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/llmResponse.ts b/agent-reactflow/apps/backend/src/functions/llmResponse.ts new file mode 100644 index 0000000..88fedc2 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/llmResponse.ts @@ -0,0 +1,46 @@ +import { FunctionFailure, log } from "@restackio/ai/function"; +import { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions"; +import z from "zod"; +import { openaiClient } from "../utils/client"; +import { Message } from "./llmChat"; +import { zodResponseFormat } from "openai/helpers/zod.mjs"; + +export type llmResponseInput = { + systemContent?: string; + model?: string; + messages: Message[]; + workflowName: string; + outputConditions: string[]; +}; + +export const llmResponse = async ({ + messages, + workflowName, + outputConditions +}: llmResponseInput): Promise => { + try { + const openai = openaiClient({}); + + const responseFormat = zodResponseFormat(z.object({ + response: z.enum(outputConditions.length > 0 ? outputConditions as [string, ...string[]] : ["success", "failure"]), + }), workflowName) + + const chatParams: ChatCompletionCreateParamsNonStreaming = { + messages: messages, + model: "gpt-4o-mini", + response_format: responseFormat, + }; + + log.debug("LLM response completion params", { + chatParams, + }); + + const completion = await openai.beta.chat.completions.parse(chatParams); + + const completionResponse = completion.choices[0].message.parsed; + + return completionResponse ?? []; + } catch (error) { + throw FunctionFailure.nonRetryable(`Error LLM response: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/mockFlow.ts b/agent-reactflow/apps/backend/src/functions/mockFlow.ts new file mode 100644 index 0000000..f33fcc0 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/mockFlow.ts @@ -0,0 +1,77 @@ +import { FunctionFailure } from "@restackio/ai/function"; +import { ReactFlowJsonObject } from "reactflow"; +import { endFlow, idVerification, manualVerification } from "../workflows"; +import z from "zod"; +import { zodResponseFormat } from "openai/helpers/zod.mjs"; + +export const mockFlow = async (): Promise => { + try { + + return { + nodes: [ + { + id: idVerification.name, + type: "workflow", + data: { + eventType: idVerification.name, + workflowType: idVerification.name, + flowPrompt: 'Given the following input, determine if id verification is successful and the user is above 35 years old or not: ', + flowOutputConditions: ["success", "successUnder35", 'failure'], + status: "initial", + }, + + position: { x: 0, y: 0 } }, + { + id: manualVerification.name, + type: "workflow", + data: { + eventType: manualVerification.name, + workflowType: manualVerification.name, + flowPrompt: 'Given the following input, determine if verification is successful or not: ', + flowOutputConditions: ["success", "failure"], + }, + position: { x: 100, y: 100 } }, + { + id: endFlow.name, + type: "default", + data: { + eventType: endFlow.name, + workflowType: endFlow.name, + }, + position: { x: 200, y: 200 } + } + ], + edges: [ + { id: `edge-${idVerification.name}-${manualVerification.name}-success`, + source: idVerification.name, + target: endFlow.name, + sourceHandle: "success", + }, + { id: `edge-${idVerification.name}-${manualVerification.name}-successAbove35`, + source: idVerification.name, + target: manualVerification.name, + sourceHandle: "successUnder35", + }, + { + id: `edge-${idVerification.name}-${manualVerification.name}-failure`, + source: idVerification.name, + target: endFlow.name, + sourceHandle: "failure", + }, + { id: `edge-${manualVerification.name}-${endFlow.name}-success`, + source: manualVerification.name, + target: endFlow.name, + sourceHandle: "success", + }, + { id: `edge-${manualVerification.name}-${endFlow.name}-failure`, + source: manualVerification.name, + target: endFlow.name, + sourceHandle: "failure", + } + ], + viewport: { x: 0, y: 0, zoom: 1 } + };; + } catch (error) { + throw FunctionFailure.nonRetryable(`Error mockFlow: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/sendAgentEvent.ts b/agent-reactflow/apps/backend/src/functions/sendAgentEvent.ts new file mode 100644 index 0000000..0ceb504 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/sendAgentEvent.ts @@ -0,0 +1,35 @@ +import { FunctionFailure } from "@restackio/ai/function"; +import { client } from "../client" + +import "dotenv/config"; + +export const sendAgentEvent = async ({ + eventName, + eventInput, + agentId, + runId, +}: { + eventName: string; + eventInput: any; + agentId: string; + runId: string; +}) => { + + try { + const sentEvent = await client.sendAgentEvent({ + event: { + name: eventName, + input: eventInput, + }, + agent: { + agentId: agentId, + runId: runId, + } + }) + + return sentEvent; + + } catch (error) { + throw FunctionFailure.nonRetryable(`Error sending agent event: ${error}`); + } +}; diff --git a/agent-reactflow/apps/backend/src/functions/stream.ts b/agent-reactflow/apps/backend/src/functions/stream.ts new file mode 100644 index 0000000..5f25656 --- /dev/null +++ b/agent-reactflow/apps/backend/src/functions/stream.ts @@ -0,0 +1,80 @@ +import { FunctionFailure, log, functionInfo, heartbeat } from "@restackio/ai/function"; +import WebSocket from "ws"; + +export async function streamToWebsocket({ + apiAddress = "localhost:9233", + data, +}: { + apiAddress?: string; + data: any; +}): Promise { + // Get workflow info from the current context + + const infoObj = { + activityId: functionInfo().activityId, + workflowId: functionInfo().workflowExecution.workflowId, + runId: functionInfo().workflowExecution.runId, + activityType: functionInfo().activityType, + taskQueue: functionInfo().taskQueue, + }; + + // Determine the protocol based on the provided API address + const protocol = apiAddress?.startsWith("localhost") ? "ws" : "wss"; + const websocketUrl = `${protocol}://${apiAddress}/stream/ws/agent?agentId=${functionInfo().workflowExecution.workflowId}&runId=${functionInfo().workflowExecution.runId}`; + + let ws: WebSocket | null = null; + let collectedMessages = ""; + + log.debug("Stream to websocket", { websocketUrl }); + + try { + // Open the WebSocket connection + try { + ws = new WebSocket(websocketUrl); + } catch (error: any) { + throw FunctionFailure.nonRetryable("Error restack stream websocket connection:", error?.message || error); + } + await new Promise((resolve, reject) => { + ws!.onopen = () => resolve(); + ws!.onerror = (err: any) => reject(err); + }); + + heartbeat(infoObj); + + log.info("data", data); + + // For asynchronous iteration + for await (const chunk of data) { + log.debug("Stream chunk", { chunk }); + const rawChunkJson = JSON.stringify(chunk); + heartbeat(rawChunkJson); + ws.send(rawChunkJson); + // Attempt to extract content from the chunk if using OpenAI-like stream responses + if ( + chunk && + Array.isArray(chunk.choices) && + chunk.choices.length > 0 && + chunk.choices[0].delta && + typeof chunk.choices[0].delta.content === "string" + ) { + collectedMessages += chunk.choices[0].delta.content; + } + } + + return collectedMessages; + } catch (error: any) { + const errorMessage = `Error with restack stream to websocket: ${error?.message || error}`; + log.error(errorMessage, { error }); + throw new FunctionFailure(errorMessage); + } finally { + // Ensure the WebSocket connection is closed properly + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send("[DONE]"); + ws.close(); + } catch (closeError) { + log.error("Error while closing websocket", { error: closeError }); + } + } + } +} \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/services.ts b/agent-reactflow/apps/backend/src/services.ts new file mode 100644 index 0000000..7f2dac8 --- /dev/null +++ b/agent-reactflow/apps/backend/src/services.ts @@ -0,0 +1,28 @@ +import { llmResponse, idVerification, humanVerification, dslInterpreter, mockFlow, sendAgentEvent, llmChat } from "./functions"; +import { client } from "./client"; + +async function services() { + const agentsPath = require.resolve("./agents"); + const workflowsPath = require.resolve("./workflows"); + try { + await Promise.all([ + client.startService({ + agentsPath: agentsPath, + functions: { dslInterpreter, mockFlow, sendAgentEvent, llmChat }, + }), + client.startService({ + workflowsPath: workflowsPath, + functions: { llmResponse, idVerification, humanVerification }, + taskQueue: "workflow" + }), + ]); + + console.log("Services running successfully."); + } catch (e) { + console.error("Failed to run services", e); + } +} + +services().catch((err) => { + console.error("Error running services:", err); +}); diff --git a/agent-reactflow/apps/backend/src/utils/client.ts b/agent-reactflow/apps/backend/src/utils/client.ts new file mode 100644 index 0000000..77fd385 --- /dev/null +++ b/agent-reactflow/apps/backend/src/utils/client.ts @@ -0,0 +1,21 @@ +import OpenAI from "openai/index"; +import "dotenv/config"; + +let openaiInstance: OpenAI | null = null; + +export const openaiClient = ({ + apiKey = process.env.OPENAI_API_KEY, +}: { + apiKey?: string; +}): OpenAI => { + if (!apiKey) { + throw new Error("API key is required to create OpenAI client."); + } + + if (!openaiInstance) { + openaiInstance = new OpenAI({ + apiKey, + }); + } + return openaiInstance; +}; diff --git a/agent-reactflow/apps/backend/src/workflows/endFlow.ts b/agent-reactflow/apps/backend/src/workflows/endFlow.ts new file mode 100644 index 0000000..8b5e3c2 --- /dev/null +++ b/agent-reactflow/apps/backend/src/workflows/endFlow.ts @@ -0,0 +1,37 @@ +import {step, workflowInfo } from "@restackio/ai/workflow"; +import * as functions from "../functions"; + + +export type EndFlowInput = { + eventData: { + response: "success" | "failure"; + } +}; + +export type EndFlowOutput = { + response: "success" | "failure"; + rawResponse: {}; +}; + +export async function endFlow(input: EndFlowInput): Promise { + + await step({}).sendAgentEvent({ + eventName: 'end', + eventInput: {}, + agentId: workflowInfo().parent?.workflowId!, + runId: workflowInfo().parent?.runId!, + }); + + if (input.eventData.response === "success") { + return { + response: "success", + rawResponse: {}, + }; + } else { + return { + response: "failure", + rawResponse: {}, + }; + } + +} \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/workflows/idVerification.ts b/agent-reactflow/apps/backend/src/workflows/idVerification.ts new file mode 100644 index 0000000..5211cfe --- /dev/null +++ b/agent-reactflow/apps/backend/src/workflows/idVerification.ts @@ -0,0 +1,43 @@ +import {step } from "@restackio/ai/workflow"; +import * as functions from "../functions"; + +export type DocCaptureWorkflowInput = { + eventData: { + type: "id" | "passport" | "driverLicense"; + documentNumber: string; + }, + flow: { + prompt: string; + outputConditions: string[]; + } +}; + +export type IdVerificationWorkflowOutput = { + response: string[]; + rawResponse: any; +} + +export async function idVerification(input: DocCaptureWorkflowInput): Promise { + + const verificationResult = await step({taskQueue: "workflow",}).idVerification({ + type: input.eventData.type, + documentNumber: input.eventData.documentNumber, + }); + + const llmResponse = await step({taskQueue: "workflow",}).llmResponse({ + messages: [ + { + role: "user", + content: `${input.flow.prompt} : ${JSON.stringify(verificationResult)}`, + }, + ], + workflowName: "idVerification", + outputConditions: input.flow.outputConditions, + }); + + return { + response: llmResponse, + rawResponse: verificationResult, + } + +} \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/workflows/index.ts b/agent-reactflow/apps/backend/src/workflows/index.ts new file mode 100644 index 0000000..4131df8 --- /dev/null +++ b/agent-reactflow/apps/backend/src/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./idVerification"; +export * from "./manualVerification"; +export * from "./endFlow"; \ No newline at end of file diff --git a/agent-reactflow/apps/backend/src/workflows/manualVerification.ts b/agent-reactflow/apps/backend/src/workflows/manualVerification.ts new file mode 100644 index 0000000..35ad2ba --- /dev/null +++ b/agent-reactflow/apps/backend/src/workflows/manualVerification.ts @@ -0,0 +1,43 @@ +import {step } from "@restackio/ai/agent"; +import * as functions from "../functions"; + +export type manualVerificationInput = { + eventData: { + context: string; + }, + flow: { + prompt?: string; + outputConditions: string[]; + } +}; + + +export type manualVerificationOutput = { + response: string[]; + rawResponse: any; +} + +export async function manualVerification(input: manualVerificationInput): Promise { + console.log("Manual Verification Workflow Executed"); + + const verificationResult = await step({taskQueue: "workflow"}).humanVerification({ + context: input.eventData.context, + }); + + const llmResponse = await step({taskQueue: "workflow"}).llmResponse({ + messages: [ + { + role: "user", + content: `${input.flow.prompt} : ${JSON.stringify(verificationResult)}`, + }, + ], + workflowName: "manualVerification", + outputConditions: input.flow.outputConditions, + }); + + return { + response: llmResponse, + rawResponse: verificationResult, + }; + +} \ No newline at end of file diff --git a/agent-reactflow/apps/backend/tsconfig.json b/agent-reactflow/apps/backend/tsconfig.json new file mode 100644 index 0000000..f4d02b9 --- /dev/null +++ b/agent-reactflow/apps/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/agent-reactflow/apps/frontend/.gitignore b/agent-reactflow/apps/frontend/.gitignore new file mode 100644 index 0000000..f886745 --- /dev/null +++ b/agent-reactflow/apps/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/agent-reactflow/apps/frontend/README.md b/agent-reactflow/apps/frontend/README.md new file mode 100644 index 0000000..a98bfa8 --- /dev/null +++ b/agent-reactflow/apps/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/agent-reactflow/apps/frontend/app/actions/agent.ts b/agent-reactflow/apps/frontend/app/actions/agent.ts new file mode 100644 index 0000000..03eca98 --- /dev/null +++ b/agent-reactflow/apps/frontend/app/actions/agent.ts @@ -0,0 +1,93 @@ +"use server"; +import Restack from "@restackio/ai"; + +const connectionOptions = { + engineId: process.env.RESTACK_ENGINE_ID!, + address: process.env.RESTACK_ENGINE_ADDRESS!, + apiKey: process.env.RESTACK_ENGINE_API_KEY!, +}; + +const client = new Restack( + process.env.RESTACK_ENGINE_API_KEY ? connectionOptions : undefined +); + +export async function runAgent({ + agentName = "agentFlow", + input = {}, +}: { + agentName: string, + input: any, +}) : Promise { + if (!agentName || !input) { + throw new Error("Agent name and input are required"); + } + + const agentId = `${Date.now()}-${agentName.toString()}`; + + const runId = await client.scheduleAgent({ + agentName, + agentId, + input, + }); + + return { + agentId, + runId + } +} + +export async function getAgentResult({ + agentId, + runId +}: { + agentId: string, + runId: string +}) : Promise { + const result = await client.getAgentResult({ + agentId, + runId + }); + return result +} + +export async function sendAgentEvent({ + agentId, + runId, + workflowName, + eventInput +}: { + agentId: string, + runId: string, + workflowName: string, + eventInput: any +}) : Promise { + + console.log("sendAgentEvent", agentId, runId, workflowName, eventInput) + + if (!agentId || !runId || !workflowName || !eventInput) { + throw new Error("Agent ID, run ID, workflow name, and event input are required"); + } + + try { + const eventPayload = { + event: { + name: "flowEvent", + input: { + name: workflowName, + input: eventInput + } + }, + agent: { + agentId, + runId + } + } + + console.log("eventPayload", eventPayload) + const result = await client.sendAgentEvent(eventPayload); + return result + } catch (error) { + console.error("Error sending agent event", error) + throw error + } +} \ No newline at end of file diff --git a/agent-reactflow/apps/frontend/app/actions/workflow.ts b/agent-reactflow/apps/frontend/app/actions/workflow.ts new file mode 100644 index 0000000..a53d8c8 --- /dev/null +++ b/agent-reactflow/apps/frontend/app/actions/workflow.ts @@ -0,0 +1,52 @@ +"use server"; +import Restack from "@restackio/ai"; + +const connectionOptions = { + engineId: process.env.RESTACK_ENGINE_ID!, + address: process.env.RESTACK_ENGINE_ADDRESS!, + apiKey: process.env.RESTACK_ENGINE_API_KEY!, +}; + +const client = new Restack( + process.env.RESTACK_ENGINE_API_KEY ? connectionOptions : undefined +); + +export async function runWorkflow({ + workflowName = "workflowFlow", + input = {}, +}: { + workflowName: string, + input: any, +}) : Promise { + if (!workflowName || !input) { + throw new Error("Workflow name and input are required"); + } + + const workflowId = `${Date.now()}-${workflowName.toString()}`; + + const runId = await client.scheduleWorkflow({ + workflowName, + workflowId, + input, + taskQueue: "workflow", + }); + + return { + workflowId, + runId + } +} + +export async function getWorkflowResult({ + workflowId, + runId +}: { + workflowId: string, + runId: string +}) : Promise { + const result = await client.getWorkflowResult({ + workflowId, + runId + }); + return result +} diff --git a/agent-reactflow/apps/frontend/app/api/chat/route.ts b/agent-reactflow/apps/frontend/app/api/chat/route.ts new file mode 100644 index 0000000..d26577d --- /dev/null +++ b/agent-reactflow/apps/frontend/app/api/chat/route.ts @@ -0,0 +1,71 @@ +import { createOpenAI, openai } from '@ai-sdk/openai'; +import { streamText, tool } from 'ai'; +import { z } from 'zod'; + +export const maxDuration = 30; + +export async function POST(req: Request) { + const { messages, agentId, runId } = await req.json(); + try { + + // TODO: use restack agent chat + // const openaiClient = createOpenAI({ + // apiKey: 'next-flow-frontend', + // baseURL: `http://localhost:9233/stream/agents/agentChat/${agentId}/${runId}`, + // }) + + // const zodObject = z.object({ + // nodes: z.array(z.object({ + // id: z.string(), + // type: z.string(), + // position: z.object({ + // x: z.number(), + // y: z.number(), + // }), + // data: z.object({ + // label: z.string(), + // description: z.string(), + // // icon: z.any(), + // handles: z.array(z.object({ + // id: z.string(), + // type: z.string(), + // position: z.string(), + // })), + // status: z.string(), + // }), + // })), + // edges: z.array(z.object({ + // id: z.string(), + // source: z.string(), + // target: z.string(), + // sourceHandle: z.string(), + // })), + // }); + + const result = streamText({ + model: openai('gpt-4o'), + messages, + tools: { + updateFlow: tool({ + description: 'Update flow', + parameters: z.object({ + flow: z.any() + }), + execute: async ({ flow }) => { + console.log("updated flow", flow); + return { + flow, + }; + }, + }), + }, + toolCallStreaming: true, + }); + + return result.toDataStreamResponse(); + + } catch (error) { + console.error(error); + return new Response("Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/agent-reactflow/apps/frontend/app/favicon.ico b/agent-reactflow/apps/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/agent-reactflow/apps/frontend/app/favicon.ico differ diff --git a/agent-reactflow/apps/frontend/app/fonts/GeistMonoVF.woff b/agent-reactflow/apps/frontend/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/agent-reactflow/apps/frontend/app/fonts/GeistMonoVF.woff differ diff --git a/agent-reactflow/apps/frontend/app/fonts/GeistVF.woff b/agent-reactflow/apps/frontend/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/agent-reactflow/apps/frontend/app/fonts/GeistVF.woff differ diff --git a/agent-reactflow/apps/frontend/app/globals.css b/agent-reactflow/apps/frontend/app/globals.css new file mode 100644 index 0000000..aeebee0 --- /dev/null +++ b/agent-reactflow/apps/frontend/app/globals.css @@ -0,0 +1,109 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + + --radius: 0.5rem; + + --chart-1: 221.2 83.2% 53.3%; + --chart-2: 142.1 76.2% 36.3%; + --chart-3: 346.8 77.2% 49.8%; + --chart-4: 43.3 96.4% 56.3%; + --chart-5: 262.1 83.3% 57.8%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.react-flow__node { + @apply select-none; +} + +.react-flow__handle { + @apply border-primary; +} + +.react-flow__attribution { + @apply hidden; +} + + +.react-flow__node-default, +.react-flow__node-input, +.react-flow__node-output, +.react-flow__node-group { + padding: 0 !important; + border-radius: 0 !important; + min-width: 152px !important; + font-size: inherit !important; + color: inherit !important; + text-align: left !important; + border: 0 !important; + background-color: transparent !important; +} diff --git a/agent-reactflow/apps/frontend/app/layout.tsx b/agent-reactflow/apps/frontend/app/layout.tsx new file mode 100644 index 0000000..bc3f45e --- /dev/null +++ b/agent-reactflow/apps/frontend/app/layout.tsx @@ -0,0 +1,30 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import "./globals.css" +import { ThemeProvider } from "../components/theme/theme-provider" + +const inter = Inter({ subsets: ["latin"] }) + +export const metadata: Metadata = { + title: "Agent Builder", + description: "Build and run agents with React Flow and Restack", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + + ) +} + +import './globals.css' \ No newline at end of file diff --git a/agent-reactflow/apps/frontend/app/page.tsx b/agent-reactflow/apps/frontend/app/page.tsx new file mode 100644 index 0000000..b782cb1 --- /dev/null +++ b/agent-reactflow/apps/frontend/app/page.tsx @@ -0,0 +1,10 @@ +import WorkflowBuilder from "../components/agent-builder" + +export default function Home() { + return ( +
+ +
+ ) +} + diff --git a/agent-reactflow/apps/frontend/components.json b/agent-reactflow/apps/frontend/components.json new file mode 100644 index 0000000..458d62d --- /dev/null +++ b/agent-reactflow/apps/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "../components", + "utils": "../lib/utils", + "ui": "../components/ui", + "lib": "../lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/agent-reactflow/apps/frontend/components/agent-builder.tsx b/agent-reactflow/apps/frontend/components/agent-builder.tsx new file mode 100644 index 0000000..d440f77 --- /dev/null +++ b/agent-reactflow/apps/frontend/components/agent-builder.tsx @@ -0,0 +1,296 @@ +"use client" + +import type React from "react" + +import { useState, useCallback, useRef, useEffect } from "react" +import { ReactFlow, + ReactFlowProvider, + Background, + addEdge, + useNodesState, + useEdgesState, + type Connection, + type Edge, + NodeTypes, + Panel, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { Button } from "./ui/button" +import { Sparkles, Workflow } from "lucide-react" +import WorkflowSelector from "./workflow-selector" +import AgentChat from "./agent-chat" +import AgentHeader from "./agent-header" +import { nodeTypes, NodeData } from "./flow/workflowNode" +import { edgeTypes } from "./flow/baseEdge" +import { nodes as initialNodes, edges as initialEdges } from "../lib/agent-init" +import AgentTestPanel from "./agent-test" +import { createNode, getLayoutedElements } from "./flow/autoLayout" +import WorkflowEditPanel from "./workflow-edit" + +export default function WorkflowBuilder() { + const reactFlowWrapper = useRef(null) + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) + const [reactFlowInstance, setReactFlowInstance] = useState(null) + const [showWorkflowSelector, setShowWorkflowSelector] = useState(false) + const [showChat, setShowChat] = useState(true) + const [showTestPanel, setShowTestPanel] = useState(false) + const [showEditPanel, setShowEditPanel] = useState(false) + const [agentName, setAgentName] = useState("Verification agent") + const [agentVersion, setAgentVersion] = useState("v1.2") + const [isLayouting, setIsLayouting] = useState(false) + const [viewMode, setViewMode] = useState<'flow' | 'json'>('flow') + + // Apply layout when nodes or edges change + const applyLayout = useCallback(async () => { + if (isLayouting || !nodes.length) return + + setIsLayouting(true) + const { nodes: layoutedNodes, edges: layoutedEdges } = await getLayoutedElements(nodes, edges) + + setNodes([...layoutedNodes]) + setEdges([...layoutedEdges]) + setIsLayouting(false) + + // Center the view after layout + if (reactFlowInstance) { + setTimeout(() => { + reactFlowInstance.fitView({ padding: 0.2 }) + }, 50) + } + }, [nodes, edges, isLayouting, setNodes, setEdges, reactFlowInstance]) + + // Initialize with initial nodes and edges + useEffect(() => { + const initializeFlow = async () => { + const { nodes: layoutedNodes, edges: layoutedEdges } = await getLayoutedElements(initialNodes, initialEdges) + setNodes(layoutedNodes) + setEdges(layoutedEdges) + } + + initializeFlow() + }, [setNodes, setEdges]) + + // Apply layout when nodes or edges change significantly + useEffect(() => { + const timer = setTimeout(() => { + applyLayout() + }, 200) + + return () => clearTimeout(timer) + }, [nodes.length, edges.length, applyLayout]) + + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]) + + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.dataTransfer.dropEffect = "move" + }, []) + + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + + const type = event.dataTransfer.getData("application/reactflow") + + if (typeof type === "undefined" || !type || !reactFlowInstance) { + return + } + + const nodeData = JSON.parse(event.dataTransfer.getData("application/nodeData")) as NodeData; + + console.log("nodeData", nodeData) + + // Create node without position (ELK will calculate it) + const newNode = createNode(type, { + ...nodeData, + }) + + console.log("newNode", newNode) + setNodes((nds) => nds.concat(newNode)) + }, + [reactFlowInstance, setNodes], + ) + + const toggleWorkflowSelector = () => { + setShowWorkflowSelector(!showWorkflowSelector) + } + + const toggleChat = () => { + setShowChat(!showChat) + } + + const openTestPanel = () => { + setShowTestPanel(true); + setShowChat(false); + setShowWorkflowSelector(false); + }; + + const setTestStatus = (workflowId: string, status: 'initial' | 'loading' | 'success' | 'error') => { + setNodes((prevNodes) => + prevNodes.map((node) => + node.id === workflowId + ? { ...node, data: { ...node.data, status } } + : node + ) + ); + }; + + const closeTestPanel = () => { + setShowTestPanel(false); + setShowChat(true); + }; + + const openEditPanel = () => { + setShowEditPanel(true); + setShowChat(false); + setShowWorkflowSelector(false); + }; + + const closeEditPanel = () => { + setShowEditPanel(false); + setShowChat(true); + }; + + const deleteNode = (nodeId: string) => { + setNodes((prevNodes) => prevNodes.filter((node) => node.id !== nodeId)); + }; + + const exportToJson = () => { + const flowData = { + nodes: nodes.map(node => ({ + id: node.id, + type: node.type, + data: node.data, + position: node.position, + })), + edges: edges.map(edge => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: edge.type, + })), + }; + return JSON.stringify(flowData, null, 2); + }; + + const toggleViewMode = () => { + setViewMode(prevMode => (prevMode === 'flow' ? 'json' : 'flow')); + }; + + const clearAllNodesAndEdges = () => { + setNodes([]); + setEdges([]); + }; + + return ( +
+ openTestPanel()} /> + +
+
+
+ + +
+
+ + {viewMode === 'flow' ? ( +
+
+ + ({ + ...node, + data: { + ...node.data, + openEditPanel, + deleteNode, + }, + }))} + edges={edges} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + onInit={setReactFlowInstance} + onDrop={onDrop} + onDragOver={onDragOver} + nodeTypes={nodeTypes as unknown as NodeTypes} + edgeTypes={edgeTypes} + fitView + defaultEdgeOptions={{ type: 'step', selectable: false, focusable: false }} + > + + + {showChat && reactFlowInstance &&( + + )} + {showTestPanel && ( + + )} + {showEditPanel && ( + + )} + +
+ + {showWorkflowSelector && } + + + + +
+ ) : ( +
+ +
+ +
{exportToJson()}
+
+ +
+ )} + + +
+
+ + +
+
+
+
+ ) +} + diff --git a/agent-reactflow/apps/frontend/components/agent-chat.tsx b/agent-reactflow/apps/frontend/components/agent-chat.tsx new file mode 100644 index 0000000..6b63c21 --- /dev/null +++ b/agent-reactflow/apps/frontend/components/agent-chat.tsx @@ -0,0 +1,204 @@ +"use client" + +import type React from "react" + +import { useRef, useEffect, useState } from "react" +import { X, Send, Sparkles } from "lucide-react" +import { Button } from "./ui/button" +import { Textarea } from "./ui/textarea" +import { ScrollArea } from "./ui/scroll-area" +import { useChat } from '@ai-sdk/react' +import { Node, Edge, ReactFlowInstance } from "@xyflow/react" +import { workflowData } from "../lib/workflowData" +// import { runAgent } from "../app/actions/agent" + +interface AgentChatProps { + onClose: () => void + reactFlowInstance: ReactFlowInstance + setNodes: (nodes: Node[]) => void + setEdges: (edges: Edge[]) => void +} + +// Sub-component for Chat Messages +function ChatMessages({ agentId, runId, currentFlow, workflowData, setNodes, setEdges }: { + agentId?: string; + runId?: string; + currentFlow: any; + workflowData: any; + setNodes: (nodes: Node[]) => void; + setEdges: (edges: Edge[]) => void; +}) { + const { messages, input, setInput, append } = useChat({ + body: { + agentId, + runId, + }, + initialMessages: [ + { + id: "1", + role: "system", + content: "You are a helpful assistant that create flows. The current flow is: " + JSON.stringify(currentFlow) + ". You are limited to the following nodes: " + JSON.stringify(workflowData) + ". Always use reactflow tool to update flow. Make sure to ask questions or clarification from user before updating flow." + } + ] + }); + + const scrollAreaRef = useRef(null); + + useEffect(() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; + } + }, [messages]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + append({ content: input, role: 'user' }); + setInput(""); + } + }; + + return ( + <> + +
+ {messages.filter(message => message.role != "system").map(message => ( +
+ {message.parts.map((part, index) => { + switch (part.type) { + case 'text': + return
+
+
+

{message.content}

+
+
+
; + case 'tool-invocation': { + const callId = part.toolInvocation.toolCallId; + + switch (part.toolInvocation.toolName) { + case 'updateFlow': { + switch (part.toolInvocation.state) { + // example of pre-rendering streaming tool calls: + case 'partial-call': + return ( +
+

Updating flow...

+
+                                  {JSON.stringify(part.toolInvocation, null, 2)}
+                                
+
+ ); + case 'call': + return ( +
+ Updating flow... +
+ ); + case 'result': + return ( +
+
+                                {JSON.stringify(part.toolInvocation.result.flow, null, 2)}
+                                
+ +
+ ); + } + } + } + } + } + })} +
+ ))} +
+
+ +
+
+ + +
+
+