Skip to content

Commit d76d316

Browse files
pallaoroclaude
andcommitted
Resizable prompt node + public OpenAPI spec for v1 routes
prompt-node: - Add @xyflow/react NodeResizer (visible when selected) so users can drag the node within 220–600 wide / 120–600 tall. - Track the typed `/` position in domToText coordinates via a new caretDomTextOffset helper. The previous saveCaretPos counted ZWS chars and pill labels, which diverged from domToText whenever the text contained pills or BR-driven newlines, breaking the slash menu when the prompt also held other `/` characters (e.g. JSON paths). - insertReference, the menuFilter computation, and the Backspace handler all use the new offset, so the typed slash is unambiguous. server: split the API surface so the public spec can never include internal routes by accident. A new publicApp (OpenAPIHono) holds the two v1 endpoints (list workflows, execute workflow) as createRoute + publicApp.openapi(...) registrations with full Zod request/response schemas. publicApp is mounted at root via app.route("/", publicApp) and its routes carry their /api/v1/... path explicitly, so paths in the spec match real URLs without a `servers` indirection (some agent renderers strip `servers`). The doc is served at /api/v1/openapi.json and mirrored at /openapi.json from the main app via publicApp.getOpenAPIDocument(...) so dashboards using the conventional root path also find it. The old internal app.doc(...) is removed — internal routes have nowhere to leak into a public spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 663ebe1 commit d76d316

2 files changed

Lines changed: 163 additions & 21 deletions

File tree

src/client/components/nodes/prompt-node.tsx

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { useState, useRef, useCallback, useEffect, useMemo, type KeyboardEvent } from "react";
2-
import { Handle, Position } from "@xyflow/react";
2+
import { Handle, Position, NodeResizer } from "@xyflow/react";
33
import type { Node } from "@xyflow/react";
44
import { useWorkflow } from "../../context";
55
import { api } from "../../api";
66
import { NodeHeader } from "./node-header";
77
import { NodeToolbar } from "./node-toolbar";
88
import type { PromptNodeData } from "../../types";
99

10-
interface Props { id: string; data: PromptNodeData; }
10+
interface Props { id: string; data: PromptNodeData; selected?: boolean; }
1111

1212
const ZWS = "\u200B";
1313

@@ -72,6 +72,61 @@ function saveCaretPos(el: HTMLElement): number {
7272
return range.toString().length;
7373
}
7474

75+
/** Offset of the caret in the same coordinate system as domToText(). */
76+
function caretDomTextOffset(el: HTMLElement): number {
77+
const sel = window.getSelection();
78+
if (!sel || sel.rangeCount === 0) return 0;
79+
const range = sel.getRangeAt(0);
80+
const target = range.startContainer;
81+
const targetOffset = range.startOffset;
82+
83+
let count = 0;
84+
let done = false;
85+
86+
function visit(node: globalThis.Node, isRoot = false) {
87+
if (done) return;
88+
if (node === target) {
89+
if (node.nodeType === globalThis.Node.TEXT_NODE) {
90+
count += (node.textContent || "").slice(0, targetOffset).replace(//g, "").length;
91+
} else {
92+
const kids = Array.from(node.childNodes);
93+
for (let i = 0; i < targetOffset && i < kids.length; i++) {
94+
visit(kids[i]);
95+
if (done) return;
96+
}
97+
}
98+
done = true;
99+
return;
100+
}
101+
if (node.nodeType === globalThis.Node.TEXT_NODE) {
102+
count += (node.textContent || "").replace(//g, "").length;
103+
return;
104+
}
105+
if (node instanceof HTMLElement) {
106+
if (node.classList.contains("prompt-pill")) {
107+
const nodeId = node.getAttribute("data-node-id") || "";
108+
count += `{{${nodeId}}}`.length;
109+
return;
110+
}
111+
if (node.tagName === "BR") {
112+
count += 1;
113+
return;
114+
}
115+
if (node.tagName === "DIV" && !isRoot) {
116+
// domToText prepends \n for nested DIVs when prior content lacks one
117+
if (count > 0) count += 1;
118+
}
119+
for (const child of node.childNodes) {
120+
visit(child);
121+
if (done) return;
122+
}
123+
}
124+
}
125+
126+
visit(el, true);
127+
return count;
128+
}
129+
75130
function restoreCaretPos(el: HTMLElement, offset: number) {
76131
const sel = window.getSelection();
77132
if (!sel) return;
@@ -115,7 +170,7 @@ function restoreCaretPos(el: HTMLElement, offset: number) {
115170
sel.addRange(range);
116171
}
117172

118-
export function PromptNode({ id, data }: Props) {
173+
export function PromptNode({ id, data, selected }: Props) {
119174
const { updateNodeData, deleteNode, nodes } = useWorkflow();
120175
const [showMenu, setShowMenu] = useState(false);
121176
const [menuFilter, setMenuFilter] = useState("");
@@ -222,12 +277,13 @@ export function PromptNode({ id, data }: Props) {
222277
if (!el) return;
223278

224279
const text = domToText(el);
225-
const search = "/" + menuFilter;
226-
const slashIdx = text.lastIndexOf(search);
227-
if (slashIdx === -1) return;
280+
const slashPos = slashPosRef.current;
281+
if (slashPos === null) return;
282+
const slashIdx = slashPos - 1;
283+
if (slashIdx < 0 || text[slashIdx] !== "/") return;
228284

229285
const before = text.slice(0, slashIdx);
230-
const after = text.slice(slashIdx + search.length);
286+
const after = text.slice(slashPos + menuFilter.length);
231287
const ref = `{{${nodeId}}}`;
232288
const newText = before + ref + after;
233289

@@ -253,8 +309,8 @@ export function PromptNode({ id, data }: Props) {
253309
updateNodeData(id, { text });
254310

255311
if (showMenu && slashPosRef.current !== null) {
256-
const caretPos = saveCaretPos(el);
257-
const typed = text.slice(slashPosRef.current, caretPos);
312+
const caretPos = caretDomTextOffset(el);
313+
const typed = text.slice(slashPosRef.current, Math.max(caretPos, slashPosRef.current));
258314
if (typed.length > 30 || (typed.includes(" ") && typed.length > 15)) {
259315
setShowMenu(false);
260316
slashPosRef.current = null;
@@ -270,7 +326,7 @@ export function PromptNode({ id, data }: Props) {
270326
if (!showMenu && e.key === "/" && promptNodes.length > 0) {
271327
const el = editorRef.current;
272328
if (!el) return;
273-
slashPosRef.current = saveCaretPos(el) + 1;
329+
slashPosRef.current = caretDomTextOffset(el) + 1;
274330
setShowMenu(true);
275331
setMenuFilter("");
276332
return;
@@ -295,7 +351,7 @@ export function PromptNode({ id, data }: Props) {
295351
} else if (e.key === "Backspace") {
296352
const el = editorRef.current;
297353
if (el && slashPosRef.current !== null) {
298-
const caretPos = saveCaretPos(el);
354+
const caretPos = caretDomTextOffset(el);
299355
if (caretPos <= slashPosRef.current - 1) {
300356
setShowMenu(false);
301357
slashPosRef.current = null;
@@ -306,15 +362,24 @@ export function PromptNode({ id, data }: Props) {
306362
}, [showMenu, filtered, menuIndex, promptNodes.length, insertReference]);
307363

308364
return (
309-
<div className="flow-node relative">
365+
<div className="flow-node relative flex flex-col" style={{ width: "100%", height: "100%", maxWidth: "none" }}>
366+
<NodeResizer
367+
isVisible={selected}
368+
minWidth={220}
369+
minHeight={120}
370+
maxWidth={600}
371+
maxHeight={600}
372+
lineClassName="!border-accent"
373+
handleClassName="!bg-accent !border-white"
374+
/>
310375
<NodeToolbar id={id} isInput={data.isInput} onToggleInput={(v) => updateNodeData(id, { isInput: v })} />
311376
<NodeHeader id={id} label={data.label} icon="&#9998;" bgClass="bg-violet-50" textClass="text-violet-600" />
312377
<div className="p-2.5 flex flex-col gap-1.5 relative">
313378
<div
314379
ref={editorRef}
315380
contentEditable
316381
className="prompt-editor nodrag nowheel w-full bg-surface-card border border-border-dim rounded text-gray-800 text-xs p-1.5 outline-none transition-colors focus:border-accent"
317-
style={{ minHeight: "64px", whiteSpace: "pre-wrap", wordBreak: "break-word" }}
382+
style={{ minHeight: "64px", maxHeight: "240px", overflowY: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word" }}
318383
onInput={onInput}
319384
onKeyDown={onKeyDown}
320385
onBlur={() => { setTimeout(() => setShowMenu(false), 200); flushAutoName(); }}

src/server/index.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { initUploads, putUpload, getUpload, readUploadAsBase64DataUrl } from "./
55
type Env = { Bindings: { DB: D1Database; UPLOADS: R2Bucket; OPENROUTER_API_KEY: string } };
66

77
const app = new OpenAPIHono<Env>();
8+
// Public sub-app — every route here MUST be registered with publicApp.openapi(...)
9+
// so it shows up in /api/v1/openapi.json. Internal routes stay on `app` and are
10+
// excluded from the public spec by virtue of living in a different app instance.
11+
const publicApp = new OpenAPIHono<Env>();
812

913
app.onError((err, c) => {
1014
console.error(err);
@@ -452,9 +456,44 @@ app.post("/api/suggest-name", async (c) => {
452456
}
453457
});
454458

455-
// ── Public workflow list (with input schema) ─────────────────────────
459+
// ── Public API (v1) ──────────────────────────────────────────────────
456460

457-
app.get("/api/v1/workflows", async (c) => {
461+
const PublicInputSchema = z.object({
462+
node_id: z.string(),
463+
type: z.enum(["text", "image_url"]),
464+
label: z.string(),
465+
});
466+
467+
const PublicWorkflowSchema = z.object({
468+
id: z.number(),
469+
name: z.string(),
470+
inputs: z.array(PublicInputSchema),
471+
created_at: z.string(),
472+
updated_at: z.string(),
473+
});
474+
475+
const ExecuteResultSchema = z.object({
476+
workflow_id: z.number(),
477+
outputs: z.array(z.record(z.unknown())),
478+
all_nodes: z.array(z.object({
479+
node_id: z.string(),
480+
type: z.string(),
481+
label: z.string(),
482+
output: z.record(z.unknown()),
483+
})),
484+
});
485+
486+
const listPublicWorkflows = createRoute({
487+
method: "get",
488+
path: "/api/v1/workflows",
489+
tags: ["workflows"],
490+
summary: "List workflows with their input schema",
491+
responses: {
492+
200: { content: { "application/json": { schema: z.array(PublicWorkflowSchema) } }, description: "OK" },
493+
},
494+
});
495+
496+
publicApp.openapi(listPublicWorkflows, async (c) => {
458497
const rows = await query<{ id: number; name: string; nodes: string; created_at: string; updated_at: string }>(
459498
"SELECT id, name, nodes, created_at, updated_at FROM workflows ORDER BY updated_at DESC",
460499
);
@@ -474,10 +513,34 @@ app.get("/api/v1/workflows", async (c) => {
474513
return c.json(workflows, 200);
475514
});
476515

477-
// ── Execute workflow ──────────────────────────────────────────────────
516+
const executeWorkflow = createRoute({
517+
method: "post",
518+
path: "/api/v1/workflows/{id}/execute",
519+
tags: ["workflows"],
520+
summary: "Run a workflow with the given input overrides",
521+
request: {
522+
params: z.object({ id: z.string() }),
523+
body: {
524+
content: {
525+
"application/json": {
526+
schema: z.object({
527+
inputs: z.record(z.string()).optional().openapi({
528+
description: "Map of input node_id → value (text or image URL).",
529+
}),
530+
}),
531+
},
532+
},
533+
},
534+
},
535+
responses: {
536+
200: { content: { "application/json": { schema: ExecuteResultSchema } }, description: "OK" },
537+
404: { content: { "application/json": { schema: ErrorSchema } }, description: "Workflow not found" },
538+
500: { content: { "application/json": { schema: ErrorSchema } }, description: "Server error" },
539+
},
540+
});
478541

479-
app.post("/api/v1/workflows/:id/execute", async (c) => {
480-
const { id } = c.req.param();
542+
publicApp.openapi(executeWorkflow, async (c) => {
543+
const { id } = c.req.valid("param");
481544
const apiKey = c.env.OPENROUTER_API_KEY;
482545
if (!apiKey) return c.json({ error: "OPENROUTER_API_KEY not set" }, 500);
483546

@@ -486,7 +549,7 @@ app.post("/api/v1/workflows/:id/execute", async (c) => {
486549
);
487550
if (!wf) return c.json({ error: "Workflow not found" }, 404);
488551

489-
const body = await c.req.json<{ inputs?: Record<string, string> }>().catch(() => ({ inputs: {} }));
552+
const body = c.req.valid("json");
490553
const inputOverrides = body.inputs || {};
491554

492555
const nodes = JSON.parse(wf.nodes) as Array<{ id: string; type: string; data: Record<string, unknown> }>;
@@ -666,8 +729,22 @@ app.post("/api/v1/workflows/:id/execute", async (c) => {
666729
}, 200);
667730
});
668731

669-
// ── OpenAPI doc ──────────────────────────────────────────────────────
732+
// ── OpenAPI doc + mount public sub-app ───────────────────────────────
733+
734+
const publicSpec = {
735+
openapi: "3.0.0" as const,
736+
info: { title: "Flow Studio API", version: "1.0.0" },
737+
};
738+
739+
publicApp.doc("/openapi.json", publicSpec);
740+
741+
// Mirror the public spec at root /openapi.json so dashboards using the
742+
// conventional path find it. Internal routes are still excluded because the
743+
// document is generated from publicApp, not app.
744+
app.get("/openapi.json", (c) => c.json(publicApp.getOpenAPIDocument(publicSpec)));
670745

671-
app.doc("/openapi.json", { openapi: "3.0.0", info: { title: "Flow Studio API", version: "3.0.0" } });
746+
// Mount at root: publicApp's routes already carry their full /api/v1/... path,
747+
// so paths in the spec match real URLs (no `servers` indirection needed).
748+
app.route("/", publicApp);
672749

673750
export default app;

0 commit comments

Comments
 (0)