From 91fe1cd88347e91e9e4d9ae7ad6eecba107c1880 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:11:44 +0530 Subject: [PATCH] feat(packages): add Langflow adapter --- .github/workflows/publish-npm-package.yml | 1 + README.md | 5 + docs/app/(home)/integrations/data.ts | 22 +- docs/content/docs/api-reference/index.mdx | 7 + docs/content/docs/api-reference/langflow.mdx | 220 ++++++++++++++++++ docs/content/docs/api-reference/meta.json | 1 + packages/langflow/README.md | 157 +++++++++++++ packages/langflow/eslint.config.cjs | 73 ++++++ packages/langflow/package.json | 69 ++++++ packages/langflow/src/__tests__/input.test.ts | 70 ++++++ .../src/__tests__/request-handler.test.ts | 111 +++++++++ .../langflow/src/__tests__/workflow.test.ts | 81 +++++++ packages/langflow/src/id.ts | 18 ++ packages/langflow/src/index.ts | 13 ++ packages/langflow/src/input.ts | 66 ++++++ packages/langflow/src/request-handler.ts | 143 ++++++++++++ packages/langflow/src/workflow.ts | 129 ++++++++++ packages/langflow/tsconfig.json | 13 ++ packages/langflow/tsconfig.test.json | 9 + packages/langflow/tsdown.config.ts | 14 ++ pnpm-lock.yaml | 10 + 21 files changed, 1231 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/api-reference/langflow.mdx create mode 100644 packages/langflow/README.md create mode 100644 packages/langflow/eslint.config.cjs create mode 100644 packages/langflow/package.json create mode 100644 packages/langflow/src/__tests__/input.test.ts create mode 100644 packages/langflow/src/__tests__/request-handler.test.ts create mode 100644 packages/langflow/src/__tests__/workflow.test.ts create mode 100644 packages/langflow/src/id.ts create mode 100644 packages/langflow/src/index.ts create mode 100644 packages/langflow/src/input.ts create mode 100644 packages/langflow/src/request-handler.ts create mode 100644 packages/langflow/src/workflow.ts create mode 100644 packages/langflow/tsconfig.json create mode 100644 packages/langflow/tsconfig.test.json create mode 100644 packages/langflow/tsdown.config.ts diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index bce42ebd9..b4e6508ee 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -19,6 +19,7 @@ on: - react-email - lang-core - langchain + - langflow - openui-cli - svelte-lang - vue-lang diff --git a/README.md b/README.md index 0a5bcd439..f0b329bc2 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Try it yourself in the [Playground](https://www.openui.com/playground): generate | :--------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | | [`@openuidev/lang-core`](./packages/lang-core) | Framework-agnostic parsing and prompt generation | Core parser, prompt-generation, runtime-evaluation, and type layer with no React, Vue, or Svelte dependency | | [`@openuidev/langchain`](./packages/langchain) | LangChain and LangGraph agents | Agent transformer and server helpers that stream OpenUI through AG-UI | +| [`@openuidev/langflow`](./packages/langflow) | Langflow workflows | Server helpers that map OpenUI turns to Workflow API v2 and relay Langflow's native AG-UI stream | | [`@openuidev/react-lang`](./packages/react-lang) | React rendering runtimes | Define component libraries, generate prompts, and render streamed OpenUI Lang in React | | [`@openuidev/react-headless`](./packages/react-headless) | Bring-your-own React chat UI | Headless chat state, streaming adapters, and message format converters | | [`@openuidev/react-ui`](./packages/react-ui) | Fastest path to a full React chat experience | Prebuilt chat layouts, standalone UI primitives, and two built-in component libraries | @@ -117,6 +118,9 @@ npm install @openuidev/lang-core # LangChain/LangGraph agent and server integration npm install @openuidev/langchain @langchain/langgraph +# Langflow Workflow API integration +npm install @openuidev/langflow + # Vue or Svelte runtime npm install @openuidev/vue-lang npm install @openuidev/svelte-lang @@ -163,6 +167,7 @@ openui/ │ ├── react-email/ # React Email component library for generated emails │ ├── lang-core/ # Framework-agnostic parser, prompt, and runtime layer │ ├── langchain/ # LangChain/LangGraph streaming integration +│ ├── langflow/ # Langflow Workflow API and AG-UI integration │ ├── vue-lang/ # Vue runtime bindings for OpenUI Lang │ ├── svelte-lang/ # Svelte runtime bindings for OpenUI Lang │ ├── browser-bundle/ # Script-tag bundle for CDN / iframe / no-build embeds diff --git a/docs/app/(home)/integrations/data.ts b/docs/app/(home)/integrations/data.ts index 1f51abf6a..3ff3c51fa 100644 --- a/docs/app/(home)/integrations/data.ts +++ b/docs/app/(home)/integrations/data.ts @@ -86,7 +86,11 @@ const integrationCatalog: Integration[] = [ howItWorks: "Each shadcn component is registered with defineComponent and a Zod prop schema. createLibrary produces both the prompt vocabulary and the renderer mapping used by the example chat app.", links: [ - { label: "Integration guide", href: "/docs/openui-lang/examples/design-systems/shadcn", kind: "Guide" }, + { + label: "Integration guide", + href: "/docs/openui-lang/examples/design-systems/shadcn", + kind: "Guide", + }, exampleLink("shadcn-chat"), { label: "shadcn/ui", href: "https://ui.shadcn.com", kind: "Website" }, ], @@ -166,6 +170,22 @@ const integrationCatalog: Integration[] = [ exampleLink("langchain-chat"), ], }, + { + slug: "langflow", + name: "Langflow", + logo: "https://raw.githubusercontent.com/langflow-ai/langflow/main/docs/static/img/langflow-icon-black-transparent.svg", + category: "ai-frameworks", + type: "Visual agent framework adapter", + summary: + "Connect Langflow workflows to OpenUI through Workflow API v2 and Langflow's native AG-UI stream.", + howItWorks: + "The @openuidev/langflow package maps OpenUI thread, follow-up, and form turns to Langflow workflow inputs and session ids. Langflow runs the flow and emits native AG-UI SSE, which agUIAdapter() consumes in AgentInterface.", + install: "npm install @openuidev/langflow", + links: [ + ...packageLinks("@openuidev/langflow", "langflow", "/docs/api-reference/langflow"), + { label: "Langflow", href: "https://www.langflow.org", kind: "Website" }, + ], + }, { slug: "vercel-ai-sdk", name: "Vercel AI SDK", diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 9096c6513..28b787b3e 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -17,6 +17,8 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/langchain`** — LangChain and LangGraph integration primitives for transforming protocol-v2 agent streams into AG-UI events and relaying them to an OpenUI frontend over Web-standard Server-Sent Events. +- **`@openuidev/langflow`** — Server-side helpers that map OpenUI chat turns to Langflow Workflow API v2 runs and relay Langflow's native AG-UI stream. + - **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. - **`@openuidev/vue-lang`** — Vue 3 bindings for defining model-renderable components, generating prompts from those definitions, and rendering streamed OpenUI Lang in Vue apps. @@ -39,6 +41,7 @@ The OpenUI SDK is split into packages that build on each other: | A ready-made React chat surface | [`@openuidev/react-ui`](/docs/api-reference/react-ui) | | OpenUI tool rendering in an assistant-ui application | [`@openuidev/assistant-ui`](/docs/api-reference/assistant-ui) | | A LangChain or LangGraph agent connected to an OpenUI frontend | [`@openuidev/langchain`](/docs/api-reference/langchain) | +| A Langflow workflow connected to an OpenUI Agent Interface | [`@openuidev/langflow`](/docs/api-reference/langflow) | | Model-generated emails | [`@openuidev/react-email`](/docs/api-reference/react-email) | | Vue integration | [`@openuidev/vue-lang`](https://github.com/thesysdev/openui/tree/main/packages/vue-lang) | | Svelte integration | [`@openuidev/svelte-lang`](https://github.com/thesysdev/openui/tree/main/packages/svelte-lang) | @@ -75,6 +78,10 @@ The OpenUI SDK is split into packages that build on each other: LangGraph stream transformation, stateless run helpers, and AG-UI SSE relay APIs for OpenUI frontends. + + Workflow API v2 request mapping, session scoping, action normalization, and native AG-UI + passthrough for OpenUI frontends. + API reference for the pre-built email templates library and prompt options. diff --git a/docs/content/docs/api-reference/langflow.mdx b/docs/content/docs/api-reference/langflow.mdx new file mode 100644 index 000000000..e535b9864 --- /dev/null +++ b/docs/content/docs/api-reference/langflow.mdx @@ -0,0 +1,220 @@ +--- +title: "@openuidev/langflow" +description: API reference for streaming Langflow Workflow API v2 runs to OpenUI over native AG-UI. +--- + +Use this package to connect an OpenUI Agent Interface to a Langflow workflow. It maps OpenUI +messages, thread ids, follow-ups, and submitted form values to Langflow's Workflow API v2 and +returns Langflow's native AG-UI stream without an intermediate protocol conversion. + +The package is framework-independent at the HTTP boundary. Its APIs use Web-standard `Request`, +`Response`, `fetch`, and `ReadableStream` types. + +## Install + +```bash tab="pnpm" tab-group="pkg" +pnpm add @openuidev/langflow +``` + +```bash tab="bun" tab-group="pkg" +bun add @openuidev/langflow +``` + +```bash tab="yarn" tab-group="pkg" +yarn add @openuidev/langflow +``` + +```bash tab="npm" tab-group="pkg" +npm install @openuidev/langflow +``` + +The integration requires a Langflow server that exposes `POST /api/v2/workflows` and supports +`stream_protocol: "agui"`. It was tested with Langflow/LFX 1.11.4. + +## Data flow + +1. OpenUI's `fetchLLM` sends `{ threadId, messages }` to an application server route. +2. `createLangflowStreamResponse()` extracts the newest user or action turn, maps `threadId` to + Langflow's `session_id`, and starts a Workflow API v2 streaming run. +3. Langflow runs the flow and returns native AG-UI Server-Sent Events. +4. OpenUI's `agUIAdapter()` consumes the response in `AgentInterface`. + +Langflow owns the workflow graph, model calls, tools, and session memory. OpenUI owns the chat +surface, OpenUI Lang parser and renderer, streaming UI state, theme, follow-ups, and form actions. + +## `createLangflowStreamResponse(request, options)` + +Creates the complete OpenUI-request-to-Langflow-response path: + +```ts +function createLangflowStreamResponse( + request: Request, + options: CreateLangflowStreamResponseOptions, +): Promise; +``` + +```ts +interface CreateLangflowStreamResponseOptions extends LangflowWorkflowOverrides { + apiUrl: string; + flowId: string; + apiKey?: string; + headers?: Record; + prepareSessionId?: (context: PrepareLangflowSessionContext) => string | Promise; + prepareInput?: (context: PrepareLangflowInputContext) => string | Promise; + debug?: boolean; + fetch?: typeof fetch; +} +``` + +The request body must contain a non-empty AG-UI `messages` array. A missing `threadId` receives an +opaque generated id. Invalid requests return JSON with status `400` without starting a Langflow +run. Upstream failures return status `502`; response details remain redacted unless trusted +development code enables `debug`. + +The helper can be returned directly from a Next.js route or another Web-standard route handler: + +```ts +import { createLangflowStreamResponse } from "@openuidev/langflow"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL ?? "http://localhost:7860", + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + debug: process.env.NODE_ENV !== "production", + }); +} +``` + +Keep the Langflow API key in the server route. Do not expose it to the browser. + +### `prepareSessionId(context)` + +OpenUI's `threadId` is client input. Authenticate the route and use `prepareSessionId` to authorize +or scope Langflow session ids in a multi-user application: + +```ts +const user = await requireUser(request); + +return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL!, + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + prepareSessionId: ({ threadId }) => `${user.id}:${threadId}`, +}); +``` + +```ts +interface PrepareLangflowSessionContext { + threadId: string; + requestBody: Record; +} +``` + +### `prepareInput(context)` + +By default, the helper sends only the newest non-empty user turn because Langflow uses +`session_id` for conversation memory. OpenUI follow-ups and forms encode a readable action plus +structured context in that turn. The helper removes the transport markers while preserving the +action label and edited form values. + +Use `prepareInput` to add trusted application context after authenticating the request: + +```ts +return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL!, + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + prepareInput: ({ inputValue, requestBody }) => + `[tenant=${String(requestBody.tenant)}] ${inputValue}`, +}); +``` + +```ts +interface PrepareLangflowInputContext { + messages: Message[]; + inputValue: string; + requestBody: Record; + sessionId: string; +} +``` + +Treat all fields in `requestBody` as untrusted client input. + +## `streamLangflowWorkflow(options)` + +Use the lower-level helper when the application already owns request parsing: + +```ts +interface StreamLangflowWorkflowOptions extends LangflowWorkflowOverrides { + apiUrl: string; + flowId: string; + inputValue: string; + sessionId: string; + apiKey?: string; + headers?: Record; + signal?: AbortSignal; + debug?: boolean; + fetch?: typeof fetch; +} + +function streamLangflowWorkflow(options: StreamLangflowWorkflowOptions): Promise; +``` + +```ts +import { streamLangflowWorkflow } from "@openuidev/langflow"; + +const response = await streamLangflowWorkflow({ + apiUrl: "http://localhost:7860", + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + inputValue: "Show quarterly revenue as a labeled bar chart", + sessionId: "thread-123", + signal: request.signal, +}); +``` + +The helper fixes `mode` to `"stream"` and `stream_protocol` to `"agui"`. It passes the upstream +response body through as AG-UI SSE. `LangflowWorkflowOverrides` supports `tweaks`, live-canvas +`data`, uploaded `files`, and partial-run `startComponentId` and `stopComponentId` values. + +## Connect the OpenUI frontend + +Langflow already emits AG-UI, so the browser uses OpenUI's maintained adapter: + +```tsx +"use client"; + +import { AgentInterface, agUIAdapter, fetchLLM } from "@openuidev/react-ui"; + +const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: agUIAdapter(), +}); + +export function Chat() { + return ; +} +``` + +Keep `AgentInterface`'s theme provider enabled. Attach the system prompt generated from the same +OpenUI component library rendered by the frontend to the Langflow Agent component. + +## Exports + +| Export | Description | +| :------------------------------------ | :---------------------------------------------------------- | +| `createLangflowStreamResponse` | Complete Web-standard request/response helper | +| `streamLangflowWorkflow` | Lower-level Workflow API v2 streaming call | +| `toLangflowInput` | Newest-turn and OpenUI action/form normalizer | +| `LangflowRequestError` | Upstream HTTP error with optional development detail | +| `CreateLangflowStreamResponseOptions` | Request helper configuration | +| `PrepareLangflowInputContext` | Values passed to `prepareInput` | +| `PrepareLangflowSessionContext` | Values passed to `prepareSessionId` | +| `StreamLangflowWorkflowOptions` | Lower-level streaming configuration | +| `LangflowWorkflowOverrides` | Workflow tweaks, data, files, and partial-run component ids | + +See Langflow's [Workflow API documentation](https://docs.langflow.org/workflow-api) for the +upstream endpoint and flow configuration. diff --git a/docs/content/docs/api-reference/meta.json b/docs/content/docs/api-reference/meta.json index 2ad690e79..cb4d3db13 100644 --- a/docs/content/docs/api-reference/meta.json +++ b/docs/content/docs/api-reference/meta.json @@ -10,6 +10,7 @@ "cli", "devtools", "langchain", + "langflow", "assistant-ui" ] } diff --git a/packages/langflow/README.md b/packages/langflow/README.md new file mode 100644 index 000000000..8e799756d --- /dev/null +++ b/packages/langflow/README.md @@ -0,0 +1,157 @@ +# `@openuidev/langflow` + +Server-side integration primitives for connecting an +[OpenUI](https://www.openui.com/) Agent Interface to a +[Langflow](https://github.com/langflow-ai/langflow) workflow. + +The package uses Langflow's public Workflow API v2 and its native AG-UI stream: + +```text +OpenUI AgentInterface + -> your server route + -> @openuidev/langflow + -> POST Langflow /api/v2/workflows (stream_protocol: "agui") + -> Langflow flow, model, tools, and session memory + -> native AG-UI SSE + -> OpenUI agUIAdapter(), parser, and renderer +``` + +Langflow owns the workflow graph, model calls, tools, and session memory. OpenUI +owns the chat shell, OpenUI Lang parser and renderer, streaming UI state, theme, +follow-ups, and form actions. The adapter maps the OpenUI `threadId` to Langflow's +`session_id` and turns the latest user/action turn into `input_value`. + +## Install + +```bash +npm install @openuidev/langflow @openuidev/react-ui +``` + +The package requires a Langflow server that exposes `POST /api/v2/workflows` and +supports `stream_protocol: "agui"`. It was tested with Langflow/LFX 1.11.4. + +## Add a server route + +`createLangflowStreamResponse` accepts and returns Web-standard request types, so +it works in Next.js, Remix, Hono, and other compatible server runtimes: + +```ts +import { createLangflowStreamResponse } from "@openuidev/langflow"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL ?? "http://localhost:7860", + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + debug: process.env.NODE_ENV !== "production", + }); +} +``` + +The route expects the standard body posted by OpenUI's `fetchLLM`, including +`{ threadId, messages }`. Credentials remain in the server route and are never +sent to the browser. + +`threadId` is client input. In a multi-user application, authenticate the route +and use `prepareSessionId` to authorize or scope it before it reaches Langflow: + +```ts +const user = await requireUser(request); + +return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL!, + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + prepareSessionId: ({ threadId }) => `${user.id}:${threadId}`, +}); +``` + +OpenUI follow-ups and form submissions encode a human-readable action plus action +context in the newest user message. The helper removes the transport markers and +preserves the action label and edited form state in Langflow's `input_value`. + +Use `prepareInput` to add application context without trusting client fields as +Langflow configuration: + +```ts +return createLangflowStreamResponse(request, { + apiUrl: process.env.LANGFLOW_API_URL!, + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + prepareInput: ({ inputValue, requestBody }) => + `[tenant=${String(requestBody.tenant)}] ${inputValue}`, +}); +``` + +## Connect Agent Interface + +Langflow already emits AG-UI, so use OpenUI's maintained `agUIAdapter()` directly: + +```tsx +"use client"; + +import { AgentInterface, agUIAdapter, fetchLLM } from "@openuidev/react-ui"; + +const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: agUIAdapter(), +}); + +export function Chat() { + return ; +} +``` + +Keep `AgentInterface`'s theme provider enabled, and render with the same OpenUI +component library whose generated system prompt is attached to the Langflow +Agent component. + +## Configure the Langflow flow + +A minimal chat flow is: + +```text +Chat Input -> Agent -> Chat Output +``` + +Set the Agent's system prompt to the prompt generated from the exact +`openuiChatLibrary` version rendered by the frontend. The flow must stream the +model's OpenUI Lang text rather than wrapping it in Markdown. + +## Lower-level workflow API + +Use `streamLangflowWorkflow` when the application already owns request parsing: + +```ts +import { streamLangflowWorkflow } from "@openuidev/langflow"; + +const response = await streamLangflowWorkflow({ + apiUrl: "http://localhost:7860", + apiKey: process.env.LANGFLOW_API_KEY, + flowId: process.env.LANGFLOW_FLOW_ID!, + inputValue: "Show quarterly revenue as a labeled bar chart", + sessionId: "thread-123", + signal: request.signal, +}); +``` + +The helper fixes `mode` to `"stream"` and `stream_protocol` to `"agui"`. It also +supports Langflow `tweaks`, live-canvas `data`, uploaded `files`, and partial-run +component ids. + +## API + +- `createLangflowStreamResponse(request, options)` - complete Agent Interface to + Langflow route helper. +- `streamLangflowWorkflow(options)` - lower-level Workflow API v2 streaming call. +- `toLangflowInput(messages)` - pure newest-turn and OpenUI action/form normalizer. +- `LangflowRequestError` - upstream HTTP failure with optional development detail. +- `CreateLangflowStreamResponseOptions`, `PrepareLangflowInputContext`, + `PrepareLangflowSessionContext`, `StreamLangflowWorkflowOptions`, and + `LangflowWorkflowOverrides` - exported TypeScript types. + +See the +[`@openuidev/langflow` API reference](https://www.openui.com/docs/api-reference/langflow) +for the complete option and export reference. diff --git a/packages/langflow/eslint.config.cjs b/packages/langflow/eslint.config.cjs new file mode 100644 index 000000000..413c49627 --- /dev/null +++ b/packages/langflow/eslint.config.cjs @@ -0,0 +1,73 @@ +const tseslint = require("@typescript-eslint/eslint-plugin"); +const typescript = require("@typescript-eslint/parser"); +const prettier = require("eslint-config-prettier"); +const unusedImports = require("eslint-plugin-unused-imports"); +const eslintPluginPrettier = require("eslint-plugin-prettier"); + +module.exports = [ + { + files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.test.json", + sourceType: "module", + }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + ignores: [ + "**/*.stories.tsx", + "**/__tests__/**/*.{ts,tsx}", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "*.config.ts", + ], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "unused-imports": unusedImports, + prettier: eslintPluginPrettier, + }, + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-undefined": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-use-before-define": [ + "error", + { + functions: false, + classes: false, + variables: false, + }, + ], + "unused-imports/no-unused-imports": "error", + "no-console": [ + "error", + { + allow: ["error", "warn", "info"], + }, + ], + ...eslintPluginPrettier.configs.recommended.rules, + }, + }, + prettier, +]; diff --git a/packages/langflow/package.json b/packages/langflow/package.json new file mode 100644 index 000000000..702276547 --- /dev/null +++ b/packages/langflow/package.json @@ -0,0 +1,69 @@ +{ + "name": "@openuidev/langflow", + "version": "0.0.1", + "description": "Langflow Workflow API integration for streaming OpenUI generative interfaces over AG-UI", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.cts", + "sideEffects": false, + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "test": "vitest run", + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src README.md", + "format:check": "prettier --check ./src README.md", + "check:publint": "publint", + "check:attw": "attw --pack .", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run check:publint && pnpm run check:attw", + "ci": "pnpm run typecheck && pnpm run test && pnpm run lint:check && pnpm run format:check" + }, + "keywords": [ + "openui", + "langflow", + "ag-ui", + "generative-ui", + "streaming", + "sse" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/langflow" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "dependencies": { + "@ag-ui/core": "^0.0.57" + }, + "devDependencies": { + "vitest": "^4.1.0" + } +} diff --git a/packages/langflow/src/__tests__/input.test.ts b/packages/langflow/src/__tests__/input.test.ts new file mode 100644 index 000000000..82c64cbbb --- /dev/null +++ b/packages/langflow/src/__tests__/input.test.ts @@ -0,0 +1,70 @@ +import type { Message } from "@ag-ui/core"; +import { describe, expect, it } from "vitest"; + +import { toLangflowInput } from "../input"; + +describe("toLangflowInput", () => { + it("sends only the newest user turn because the thread maps to Langflow memory", () => { + const input = toLangflowInput([ + userMessage("user-1", "Compare Python and Rust adoption."), + assistantMessage("assistant-1", "root = Card([])"), + userMessage("user-2", "Now show the five-year trend."), + ]); + + expect(input).toBe("Now show the five-year trend."); + }); + + it("keeps a clicked follow-up and its action context", () => { + const input = toLangflowInput([ + userMessage( + "user-1", + ']]\u003eopenui:content\nShow the five-year trend\n]]\u003eopenui:context\n["User clicked: Show the five-year trend"]', + ), + ]); + + expect(input).toBe("Show the five-year trend\nUser clicked: Show the five-year trend"); + }); + + it("passes edited form values and action context to Langflow", () => { + const input = toLangflowInput([ + userMessage( + "user-1", + ']]\u003eopenui:content\nSubmit project\n]]\u003eopenui:context\n["User clicked: Submit project",{"name":"Ada Lovelace","email":"ada@example.com","teamSize":"11-25"}]', + ), + ]); + + expect(input).toContain("Submit project"); + expect(input).toContain("Ada Lovelace"); + expect(input).toContain("ada@example.com"); + expect(input).toContain('"teamSize":"11-25"'); + }); + + it("reads text blocks from multimodal user content", () => { + const input = toLangflowInput([ + { + id: "user-1", + role: "user", + content: [ + { type: "text", text: "Describe this file" }, + { type: "binary", mimeType: "image/png", data: "aGVsbG8=" }, + ], + }, + ]); + + expect(input).toBe("Describe this file"); + }); + + it("rejects a request without a non-empty user turn", () => { + expect(() => toLangflowInput([assistantMessage("assistant-1", "Hello")])).toThrow( + "Expected at least one non-empty user message", + ); + }); +}); + +function userMessage(id: string, content: string): Message { + return { id, role: "user", content }; +} + +function assistantMessage(id: string, content: string): Message { + return { id, role: "assistant", content }; +} diff --git a/packages/langflow/src/__tests__/request-handler.test.ts b/packages/langflow/src/__tests__/request-handler.test.ts new file mode 100644 index 000000000..e928b26d1 --- /dev/null +++ b/packages/langflow/src/__tests__/request-handler.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createLangflowStreamResponse } from "../request-handler"; + +describe("createLangflowStreamResponse", () => { + it("maps an OpenUI thread and latest action turn to Langflow", async () => { + const nativeStream = [ + 'data: {"type":"RUN_STARTED","threadId":"thread-7","runId":"run-1"}', + 'data: {"type":"TEXT_MESSAGE_START","messageId":"message-1","role":"assistant"}', + 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"message-1","delta":"root = Card([])"}', + 'data: {"type":"TEXT_MESSAGE_END","messageId":"message-1"}', + 'data: {"type":"RUN_FINISHED","threadId":"thread-7","runId":"run-1"}', + "", + ].join("\n\n"); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(nativeStream, { headers: { "Content-Type": "text/event-stream" } }), + ); + const request = chatRequest({ + threadId: "thread-7", + messages: [ + { id: "user-1", role: "user", content: "Build an intake form" }, + { id: "assistant-1", role: "assistant", content: "root = Card([])" }, + { + id: "user-2", + role: "user", + content: + ']]\u003eopenui:content\nSubmit project\n]]\u003eopenui:context\n["User clicked: Submit project",{"project":"Aurora-731","teamSize":7}]', + }, + ], + }); + + const response = await createLangflowStreamResponse(request, { + apiUrl: "http://localhost:7860", + apiKey: "secret", + flowId: "flow-1", + fetch: fetchMock, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(nativeStream); + const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(payload.session_id).toBe("thread-7"); + expect(payload.stream_protocol).toBe("agui"); + expect(payload.input_value).toContain("Aurora-731"); + expect(payload.input_value).not.toContain("Build an intake form"); + }); + + it("supports application-specific input preparation", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("data: {}\n\n")); + const request = chatRequest({ + threadId: "thread-1", + tenant: "acme", + messages: [{ id: "user-1", role: "user", content: "Hello" }], + }); + + await createLangflowStreamResponse(request, { + apiUrl: "http://localhost:7860", + flowId: "flow-1", + fetch: fetchMock, + prepareSessionId: ({ threadId }) => `tenant-acme:${threadId}`, + prepareInput: ({ inputValue, requestBody }) => + `[tenant=${String(requestBody.tenant)}] ${inputValue}`, + }); + + const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(payload.input_value).toBe("[tenant=acme] Hello"); + expect(payload.session_id).toBe("tenant-acme:thread-1"); + }); + + it("returns 400 for malformed chat requests", async () => { + const response = await createLangflowStreamResponse(chatRequest({ messages: [] }), { + apiUrl: "http://localhost:7860", + flowId: "flow-1", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Expected a JSON request body containing a non-empty messages array", + }); + }); + + it("returns a sanitized 502 when Langflow rejects the run", async () => { + const response = await createLangflowStreamResponse( + chatRequest({ messages: [{ id: "user-1", role: "user", content: "Hello" }] }), + { + apiUrl: "http://localhost:7860", + flowId: "flow-1", + fetch: vi + .fn() + .mockResolvedValue( + Response.json({ detail: "sensitive upstream detail" }, { status: 401 }), + ), + }, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: "Langflow workflow request failed with HTTP 401", + }); + }); +}); + +function chatRequest(body: Record): Request { + return new Request("http://localhost/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/packages/langflow/src/__tests__/workflow.test.ts b/packages/langflow/src/__tests__/workflow.test.ts new file mode 100644 index 000000000..cbf149528 --- /dev/null +++ b/packages/langflow/src/__tests__/workflow.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LangflowRequestError, streamLangflowWorkflow } from "../workflow"; + +describe("streamLangflowWorkflow", () => { + it("calls Workflow API v2 with native AG-UI streaming", async () => { + const nativeStream = 'id: 0\ndata: {"type":"RUN_STARTED","threadId":"thread-1"}\n\n'; + const abortController = new AbortController(); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(nativeStream, { headers: { "Content-Type": "text/event-stream" } }), + ); + + const response = await streamLangflowWorkflow({ + apiUrl: "http://localhost:7860/", + apiKey: "server-only-key", + flowId: "flow-1", + inputValue: "Show a chart", + sessionId: "thread-1", + tweaks: { Agent: { model_name: "gpt-5.5" } }, + signal: abortController.signal, + fetch: fetchMock, + }); + + expect(await response.text()).toBe(nativeStream); + expect(fetchMock).toHaveBeenCalledOnce(); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("http://localhost:7860/api/v2/workflows"); + expect(init?.signal).toBe(abortController.signal); + expect(new Headers(init?.headers).get("x-api-key")).toBe("server-only-key"); + expect(JSON.parse(String(init?.body))).toEqual({ + flow_id: "flow-1", + input_value: "Show a chart", + session_id: "thread-1", + mode: "stream", + stream_protocol: "agui", + tweaks: { Agent: { model_name: "gpt-5.5" } }, + }); + }); + + it("redacts upstream error bodies unless debug is enabled", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ detail: "internal deployment detail" }, { status: 422 })); + + const regularError = await captureError( + streamLangflowWorkflow({ + apiUrl: "http://localhost:7860", + flowId: "flow-1", + inputValue: "Hello", + sessionId: "thread-1", + fetch: fetchMock, + }), + ); + expect(regularError).toBeInstanceOf(LangflowRequestError); + expect((regularError as LangflowRequestError).detail).toBeUndefined(); + + const debugError = await captureError( + streamLangflowWorkflow({ + apiUrl: "http://localhost:7860", + flowId: "flow-1", + inputValue: "Hello", + sessionId: "thread-1", + debug: true, + fetch: fetchMock, + }), + ); + expect((debugError as LangflowRequestError).detail).toContain("internal deployment detail"); + }); +}); + +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error("Expected promise to reject"); +} diff --git a/packages/langflow/src/id.ts b/packages/langflow/src/id.ts new file mode 100644 index 000000000..ab6d7ea79 --- /dev/null +++ b/packages/langflow/src/id.ts @@ -0,0 +1,18 @@ +/** Creates an opaque id without requiring a Node-only import. */ +export function createId(): string { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + + const bytes = new Uint8Array(16); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/packages/langflow/src/index.ts b/packages/langflow/src/index.ts new file mode 100644 index 000000000..7992dab5e --- /dev/null +++ b/packages/langflow/src/index.ts @@ -0,0 +1,13 @@ +export { toLangflowInput } from "./input"; +export { + createLangflowStreamResponse, + type CreateLangflowStreamResponseOptions, + type PrepareLangflowInputContext, + type PrepareLangflowSessionContext, +} from "./request-handler"; +export { + LangflowRequestError, + streamLangflowWorkflow, + type LangflowWorkflowOverrides, + type StreamLangflowWorkflowOptions, +} from "./workflow"; diff --git a/packages/langflow/src/input.ts b/packages/langflow/src/input.ts new file mode 100644 index 000000000..f93bee4f8 --- /dev/null +++ b/packages/langflow/src/input.ts @@ -0,0 +1,66 @@ +import type { Message } from "@ag-ui/core"; + +const CONTENT_MARKER = "]]\u003eopenui:content"; +const CONTEXT_MARKER = "]]\u003eopenui:context"; + +/** + * Returns the newest user turn in a form suitable for Langflow's `input_value`. + * + * OpenUI action turns include a human-friendly message plus an encoded context + * block. This function keeps both, including edited form state, while removing + * the transport markers themselves. + */ +export function toLangflowInput(messages: Message[]): string { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== "user") continue; + + const text = actionAwareUserText(message.content); + if (text) return text; + } + + throw new Error("Expected at least one non-empty user message"); +} + +function rawMessageText(content: Extract["content"]): string { + if (typeof content === "string") return content; + return content + .map((part) => (part.type === "text" ? part.text : "")) + .filter(Boolean) + .join(""); +} + +function actionAwareUserText(content: Extract["content"]): string { + const raw = rawMessageText(content); + const contentIndex = raw.lastIndexOf(CONTENT_MARKER); + const contextIndex = raw.lastIndexOf(CONTEXT_MARKER); + if (contentIndex === -1 && contextIndex === -1) return raw.trim(); + + const contentStart = contentIndex === -1 ? 0 : lineContentStart(raw, contentIndex); + const contentEnd = contextIndex === -1 ? raw.length : contextIndex; + const visibleContent = raw.slice(contentStart, contentEnd).trim(); + if (contextIndex === -1) return visibleContent; + + const rawContext = raw.slice(lineContentStart(raw, contextIndex)).trim(); + return [visibleContent, formatActionContext(rawContext)].filter(Boolean).join("\n"); +} + +function lineContentStart(value: string, markerIndex: number): number { + const newline = value.indexOf("\n", markerIndex); + return newline === -1 ? markerIndex : newline + 1; +} + +function formatActionContext(rawContext: string): string { + if (!rawContext) return ""; + + try { + const parsed = JSON.parse(rawContext) as unknown; + const items = Array.isArray(parsed) ? parsed : [parsed]; + return items + .map((item) => (typeof item === "string" ? item : JSON.stringify(item))) + .filter((item): item is string => Boolean(item)) + .join("\n"); + } catch { + return rawContext; + } +} diff --git a/packages/langflow/src/request-handler.ts b/packages/langflow/src/request-handler.ts new file mode 100644 index 000000000..f44daa082 --- /dev/null +++ b/packages/langflow/src/request-handler.ts @@ -0,0 +1,143 @@ +import { MessageSchema, type Message } from "@ag-ui/core"; + +import { createId } from "./id"; +import { toLangflowInput } from "./input"; +import { + LangflowRequestError, + streamLangflowWorkflow, + type LangflowWorkflowOverrides, +} from "./workflow"; + +export interface PrepareLangflowInputContext { + /** Validated OpenUI/AG-UI messages from the incoming request. */ + messages: Message[]; + /** The default input derived from the newest user message and action context. */ + inputValue: string; + /** The complete incoming JSON body, including fields such as `threadId`. */ + requestBody: Record; + /** The OpenUI thread id that will be used as Langflow's session id. */ + sessionId: string; +} + +export interface PrepareLangflowSessionContext { + /** The OpenUI thread id from the request, or a generated id when it was omitted. */ + threadId: string; + /** The complete incoming JSON body. Treat its fields as untrusted client input. */ + requestBody: Record; +} + +export interface CreateLangflowStreamResponseOptions extends LangflowWorkflowOverrides { + /** Langflow server root, for example `http://localhost:7860`. */ + apiUrl: string; + /** Flow id sent to `POST /api/v2/workflows`. */ + flowId: string; + /** Optional Langflow API key. Keep this value in the server route. */ + apiKey?: string; + /** Additional upstream headers. */ + headers?: Record; + /** Scope or authorize the Langflow session id before the workflow call. */ + prepareSessionId?: (context: PrepareLangflowSessionContext) => string | Promise; + /** Customize the text sent to Langflow after OpenUI action/form normalization. */ + prepareInput?: (context: PrepareLangflowInputContext) => string | Promise; + /** Include a bounded upstream error excerpt in trusted development responses. */ + debug?: boolean; + /** Override `fetch` for tests or a custom server runtime. */ + fetch?: typeof fetch; +} + +/** + * Converts an OpenUI Agent Interface request into a Langflow Workflow API v2 + * run and returns Langflow's native AG-UI Server-Sent Event stream. + */ +export async function createLangflowStreamResponse( + request: Request, + options: CreateLangflowStreamResponseOptions, +): Promise { + let body: unknown; + try { + body = (await request.json()) as unknown; + } catch { + return badRequest("Expected a JSON request body containing a non-empty messages array"); + } + + const requestBody = asRecord(body); + const messages = parseMessages(requestBody["messages"]); + if (!messages) { + return badRequest("Expected a JSON request body containing a non-empty messages array"); + } + + let inputValue: string; + try { + inputValue = toLangflowInput(messages); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Unable to prepare Langflow input"); + } + + const threadId = optionalNonEmptyString(requestBody["threadId"]) ?? createId(); + let sessionId = threadId; + if (options.prepareSessionId) { + try { + sessionId = (await options.prepareSessionId({ threadId, requestBody })).trim(); + if (!sessionId) return badRequest("Prepared Langflow session id must not be empty"); + } catch (error) { + return badRequest( + error instanceof Error ? error.message : "Unable to prepare Langflow session id", + ); + } + } + + if (options.prepareInput) { + try { + inputValue = await options.prepareInput({ messages, inputValue, requestBody, sessionId }); + if (!inputValue.trim()) return badRequest("Prepared Langflow input must not be empty"); + } catch (error) { + return badRequest( + error instanceof Error ? error.message : "Unable to prepare Langflow input", + ); + } + } + + try { + return await streamLangflowWorkflow({ + apiUrl: options.apiUrl, + flowId: options.flowId, + inputValue, + sessionId, + apiKey: options.apiKey, + headers: options.headers, + signal: request.signal, + debug: options.debug, + fetch: options.fetch, + tweaks: options.tweaks, + data: options.data, + files: options.files, + startComponentId: options.startComponentId, + stopComponentId: options.stopComponentId, + }); + } catch (error) { + if (request.signal.aborted) { + return Response.json({ error: "Langflow workflow request was aborted" }, { status: 499 }); + } + + const message = error instanceof Error ? error.message : "Langflow workflow request failed"; + const detail = error instanceof LangflowRequestError ? error.detail : undefined; + return Response.json({ error: message, ...(detail ? { detail } : {}) }, { status: 502 }); + } +} + +function parseMessages(value: unknown): Message[] | undefined { + const parsed = MessageSchema.array().safeParse(value); + return parsed.success && parsed.data.length > 0 ? parsed.data : undefined; +} + +function optionalNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function badRequest(message: string): Response { + return Response.json({ error: message }, { status: 400 }); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} diff --git a/packages/langflow/src/workflow.ts b/packages/langflow/src/workflow.ts new file mode 100644 index 000000000..0a50e893e --- /dev/null +++ b/packages/langflow/src/workflow.ts @@ -0,0 +1,129 @@ +export interface LangflowWorkflowOverrides { + /** Per-component parameter overrides keyed by Langflow component id. */ + tweaks?: Record; + /** Optional live-canvas flow definition override. */ + data?: Record; + /** Paths of files already uploaded to Langflow. */ + files?: string[]; + /** Start a partial run at this component. */ + startComponentId?: string; + /** Stop a partial run at this component. */ + stopComponentId?: string; +} + +export interface StreamLangflowWorkflowOptions extends LangflowWorkflowOverrides { + /** Langflow server root, for example `http://localhost:7860`. */ + apiUrl: string; + /** Flow id sent to `POST /api/v2/workflows`. */ + flowId: string; + /** Text sent as the workflow's `input_value`. */ + inputValue: string; + /** Langflow session id. Reuse it to preserve conversation memory. */ + sessionId: string; + /** Optional Langflow API key, sent only as the server-side `x-api-key` header. */ + apiKey?: string; + /** Additional upstream headers. Core content and authentication headers take precedence. */ + headers?: Record; + /** Aborts the upstream workflow request. */ + signal?: AbortSignal; + /** Include a bounded upstream response excerpt in thrown errors. Defaults to `false`. */ + debug?: boolean; + /** Override `fetch` for tests or a custom server runtime. */ + fetch?: typeof fetch; +} + +export class LangflowRequestError extends Error { + readonly status: number; + readonly detail?: string; + + constructor(status: number, detail?: string) { + super(`Langflow workflow request failed with HTTP ${status}`); + this.name = "LangflowRequestError"; + this.status = status; + this.detail = detail; + } +} + +/** + * Starts a streaming Workflow API v2 run using Langflow's native AG-UI protocol. + * The returned response can be passed directly to OpenUI's `agUIAdapter()`. + */ +export async function streamLangflowWorkflow({ + apiUrl, + flowId, + inputValue, + sessionId, + apiKey, + headers: extraHeaders, + signal, + debug = false, + fetch: customFetch, + tweaks, + data, + files, + startComponentId, + stopComponentId, +}: StreamLangflowWorkflowOptions): Promise { + const baseUrl = requiredValue(apiUrl, "apiUrl").replace(/\/+$/, ""); + const normalizedFlowId = requiredValue(flowId, "flowId"); + const normalizedInput = requiredValue(inputValue, "inputValue"); + const normalizedSessionId = requiredValue(sessionId, "sessionId"); + const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); + + const headers = new Headers(extraHeaders); + headers.set("Accept", "text/event-stream"); + headers.set("Content-Type", "application/json"); + if (apiKey?.trim()) headers.set("x-api-key", apiKey.trim()); + + const body = { + flow_id: normalizedFlowId, + input_value: normalizedInput, + session_id: normalizedSessionId, + mode: "stream", + stream_protocol: "agui", + ...(tweaks ? { tweaks } : {}), + ...(data ? { data } : {}), + ...(files ? { files } : {}), + ...(startComponentId ? { start_component_id: startComponentId } : {}), + ...(stopComponentId ? { stop_component_id: stopComponentId } : {}), + }; + + const upstream = await fetchImpl(`${baseUrl}/api/v2/workflows`, { + method: "POST", + headers, + body: JSON.stringify(body), + cache: "no-store", + signal, + }); + + if (!upstream.ok) { + const detail = debug ? await responseExcerpt(upstream) : undefined; + throw new LangflowRequestError(upstream.status, detail); + } + if (!upstream.body) { + throw new Error("Langflow returned an empty workflow stream"); + } + + return new Response(upstream.body, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("Content-Type") ?? "text/event-stream", + "Cache-Control": "no-cache, no-transform", + }, + }); +} + +function requiredValue(value: string, name: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`Langflow ${name} must not be empty`); + return normalized; +} + +async function responseExcerpt(response: Response): Promise { + try { + const text = (await response.text()).trim(); + return text ? text.slice(0, 2_000) : undefined; + } catch { + return undefined; + } +} diff --git a/packages/langflow/tsconfig.json b/packages/langflow/tsconfig.json new file mode 100644 index 000000000..050ddeb9a --- /dev/null +++ b/packages/langflow/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true + } +} diff --git a/packages/langflow/tsconfig.test.json b/packages/langflow/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/langflow/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/langflow/tsdown.config.ts b/packages/langflow/tsdown.config.ts new file mode 100644 index 000000000..bd5f1a3e1 --- /dev/null +++ b/packages/langflow/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + target: "es2022", + outDir: "dist", + clean: true, + deps: { + neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c7eb2385..648129511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,6 +340,16 @@ importers: specifier: ^4.2.0 version: 4.4.3 + packages/langflow: + dependencies: + '@ag-ui/core': + specifier: ^0.0.57 + version: 0.0.57 + devDependencies: + vitest: + specifier: ^4.1.0 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@26.1.0)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/observability: devDependencies: vitest: