@flowget/ai-chat
v0.1.1
Published
Builder-agnostic AI workflow-authoring chat for Flowget — an assistant-ui chat panel (./react) + an SSE BFF helper (./server) over @flowget/ai's streaming author(). Bring your own builder via the currentGraph/applyGraph seam.
Maintainers
Readme
@flowget/ai-chat
Builder-agnostic AI workflow-authoring chat for Flowget.
A user describes a workflow in natural language; the chat streams a proposal, shows an approval card, and — once approved — applies the laid-out graph to your canvas. Editing works the same way (the current graph is the context).
Built on assistant-ui primitives (streaming, approval gate, thread) and
@flowget/ai's streaming author() — with
no lock-in: the LLM stays behind @flowget/ai's BYO adapter, and the chat
never imports your builder.
Two entry points
| Import | Runs | What |
| --- | --- | --- |
| @flowget/ai-chat/react | browser | <WorkflowChat> — the chat panel + streaming runtime + proposal card |
| @flowget/ai-chat/server | your BFF | createChatStreamResponse() — SSE over @flowget/ai's authorStream() |
| @flowget/ai-chat/styles.css | — | the stylesheet (import once) |
Install
npm install @flowget/ai-chat @flowget/ai @assistant-ui/reactreact, react-dom, @assistant-ui/react are peer deps (you provide them).
@flowget/ai is an optional peer — install it only if you use the
/server helper (the authoring engine runs there). A react-only consumer with
its own backend transport can skip it.
Client — mount the chat
Drop <WorkflowChat> inside your builder. It's builder-agnostic: pass the live
currentGraph and an applyGraph callback (e.g. your store's setGraph). The
chat proposes; you apply.
import "@flowget/ai-chat/styles.css";
import { WorkflowChat } from "@flowget/ai-chat/react";
import { useFlowgetWorkflow } from "@flowget/builder"; // your builder
function BuilderChat() {
const { graph, setGraph } = useFlowgetWorkflow();
return <WorkflowChat currentGraph={graph} applyGraph={setGraph} />;
}
// inside your builder's children slot:
// <FlowgetBuilder ...><BuilderChat /></FlowgetBuilder>WorkflowChat props:
| Prop | Type | |
| --- | --- | --- |
| currentGraph | WorkflowGraph | live canvas graph (edit context + layout baseline) |
| applyGraph | (g: WorkflowGraph) => void | commit a laid-out graph to the canvas |
| transport? | ChatStreamTransport | fully custom backend transport (overrides endpoint) |
| endpoint? | string | endpoint for the default SSE transport (/api/chat) |
| heading? | string | panel heading (default "Workflow AI") |
| subtitle? | string | panel subtitle (default "Draft & edit with a prompt") |
| theme? | string | stamp data-theme on the body-portaled panel so a scoped [data-theme] token override reaches it (see Theming) |
| examples? | readonly string[] | empty-state suggestion chips; [] hides them |
| layout? | WorkflowLayout | lay a proposal out before apply (default layoutProposal) — see below |
| actor? | ChatActor | caller-identity hint — untrusted on the wire (see Security) |
| context? | unknown | opaque continuity payload forwarded to the model |
Layout — delegate to your builder
By default the chat lays each proposal out with the built-in layoutProposal
(a zero-dependency BFS layered layout that preserves the positions of nodes
already on the canvas). A builder-host with a better layout — e.g. a dagre
autoLayout / merge — injects it via the layout prop, keeping ai-chat
builder-agnostic (the host supplies the function; the chat never imports your
builder):
import { WorkflowChat, type WorkflowLayout } from "@flowget/ai-chat/react";
const layout: WorkflowLayout = (proposed, current) => mergeGraph(current, proposed);
<WorkflowChat currentGraph={graph} applyGraph={setGraph} layout={layout} />;layout: (proposed: ProposedGraph, current: WorkflowGraph) => WorkflowGraph.
layoutProposal stays exported as the default / fallback.
Headless mode
<WorkflowChat> is a turnkey shell built from smaller pieces. To bring your own
shell — or render assistant-ui primitives yourself — compose them directly:
import {
ChatRuntimeProvider,
httpChatStreamTransport,
layoutProposal,
} from "@flowget/ai-chat/react";
// getCurrentGraph must be referentially stable (a ref getter, not an inline fn)
<ChatRuntimeProvider getCurrentGraph={getCurrentGraph} transport={httpChatStreamTransport("/api/chat")}>
{/* your own assistant-ui <Thread>, composer, and proposal rendering */}
</ChatRuntimeProvider>ChatRuntimeProvider— wires assistant-ui'sLocalRuntimeto the authoring adapter and provides it to your subtree.createWorkflowChatAdapter(...)— the rawChatModelAdapter, if you drive the runtime yourself.httpChatStreamTransport(endpoint?)— the default SSE transport; swap it for anyChatStreamTransport(an async generator ofChatStreamEvents) to reach a custom backend.layoutProposal(proposed, current?)— the pure layout helper (BFS layered layout; preserves the positions of nodes that already exist on the canvas).
Server — the BFF route
Wire your @flowget/ai config (your node catalog + LLM adapter) into the SSE
helper. The engine runs entirely server-side.
import { createChatStreamResponse } from "@flowget/ai-chat/server";
import { finalizeConfig, resolveCatalog, openaiAdapter } from "@flowget/ai";
const catalog = await resolveCatalog({ registryDir: process.env.REGISTRY_DIR! });
const config = finalizeConfig(
{ adapter: openaiAdapter({ apiKey: process.env.OPENROUTER_API_KEY! }), catalog },
catalog,
);
// e.g. a Bun / Node / edge handler for POST /api/chat
export async function POST(req: Request): Promise<Response> {
// ⚠️ Your auth + rate-limiting run FIRST — see Security below.
const session = await getSession(req);
if (!session) return new Response("Unauthorized", { status: 401 });
return createChatStreamResponse(req, config, {
// Inject a SERVER-DERIVED actor over the untrusted client value:
buildRequest: (parsed) => ({ ...parsed, actor: session.actor }),
});
}createChatStreamResponse(req, config, options?) — parses + validates the
request, then streams the authoring result as SSE. Options:
| Option | | |
| --- | --- | --- |
| buildRequest? | (parsed, req) => AuthorRequest | rebuild the request — inject a server-derived actor / context |
| onError? | (err) => string | map an error to a client-safe message (default "Authoring failed") |
| maxBodyBytes? | number | request-body cap (default 1 MiB) |
| maxCommandLength? | number | command length cap (default 8000) |
Need to build the AuthorRequest yourself? Use the lower-level
streamAuthorSSE(request, config, { signal?, onError? }).
The wire format between /server and /react is SSE: one data:-framed event
per line — text-delta* then a terminal proposal | message, or error.
Security
The /server helper is unauthenticated by design and does no
rate-limiting. It is streaming plumbing, not a security boundary.
You MUST:
- Put your own authentication and authorization in front of the route (reject unauthenticated requests before calling the helper).
- Add rate-limiting / abuse protection — each request drives an LLM call.
- Treat the client-supplied
actoras untrusted. It is plain JSON on the request body; a browser can send anything. If any@flowget/aitoolset or authorizer gates data access on the actor (e.g. bytenantId), derive the actor server-side (from your session/JWT) and inject it withbuildRequest— and/or verify it with a@flowget/aiauthorizer. Otherwise a client can spoof it.
Server-side errors are logged on the server and never forwarded verbatim to the
browser (the client only sees a generic message, or your onError mapping).
Theming
Import the stylesheet once — it is self-contained and renders correctly with
no host theme. Every value reads a --flowget-* design token with a built-in
fallback, so where your app defines those tokens (light/dark), the chat inherits
them automatically; where it doesn't, the fallback applies.
Overridable design tokens (set them on :root or any ancestor):
| Token | Fallback | Used for |
| --- | --- | --- |
| --flowget-color-accent | #6366f1 | primary / brand accent |
| --flowget-color-text | #0f172a | body text |
| --flowget-color-text-subtle | #64748b | secondary text |
| --flowget-color-text-inverse | #ffffff | text on the accent |
| --flowget-color-bg | #ffffff | input / chip background |
| --flowget-color-bg-elevated | #ffffff | panel background |
| --flowget-color-surface-strong | #f8fafc | proposal-card surface |
| --flowget-color-border | #e2e8f0 | borders |
| --flowget-color-border-strong | #cbd5e1 | hover borders |
| --flowget-color-danger | #dc2626 | error state |
| --flowget-color-success | #16a34a | applied confirmation |
| --flowget-radius-lg / -md | 14px / 9px | corner radii |
| --flowget-shadow-lg | 0 12px 32px -8px rgba(15,23,42,.28) | panel / launcher shadow |
| --flowget-font-sans / -mono | system stacks | typography |
| --flowget-font-size-sm / -xs / -md | 13px / 11px / 15px | type scale |
| --flowget-motion-fast | 140ms | transition duration |
Two package-owned knobs control placement (class prefix: fg-aichat-):
| Token | Fallback | |
| --- | --- | --- |
| --fg-aichat-z | 200 | stacking order of the launcher + panel |
| --fg-aichat-launcher-offset | 24px | gap from the bottom-right corner |
⚠️ Scoped theme overrides and the body portal
The chat panel portals into document.body — outside your builder wrapper.
(The launcher is inline and already inherits your theme; only the panel
portals.) So a token override you scope to a selector on that wrapper doesn't
cascade into the panel. If you theme the builder with a scoped block like
[data-theme="midnight"] { --flowget-color-accent: #22d3ee; /* … */ }(the same block <FlowgetBuilder theme="midnight"> stamps data-theme for),
those tokens fall back to :root inside the portaled panel unless you pass the
matching theme:
<WorkflowChat currentGraph={graph} applyGraph={setGraph} theme="midnight" />theme stamps data-theme="midnight" on the panel root (.fg-aichat-panel), so
the scoped block now matches and the tokens cascade into the panel. It's a
plain string (builder-agnostic — not tied to any named-theme enum) and is
omitted entirely when unset, so unscoped (:root-defined) tokens keep
working with no change.
How it fits together
browser your BFF @flowget/ai
──────── ──────── ───────────
<WorkflowChat> createChatStreamResponse(req,cfg)
LocalRuntime ── POST /api/chat ──► authorStream(cfg, request) ──► BYO adapter (LLM)
(streams text) ◄──── SSE ──────── (text-delta* → proposal|message)
proposal card → Apply → applyGraph(graph) (your canvas)License
FSL-1.1-ALv2
