weifuwu 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +532 -0
- package/dist/router.d.ts +7 -1
- package/dist/workflow/engine.d.ts +7 -0
- package/dist/workflow/index.d.ts +6 -0
- package/dist/workflow/llm.d.ts +10 -0
- package/dist/workflow/nodes.d.ts +10 -0
- package/dist/workflow/reference.d.ts +3 -0
- package/dist/workflow/sse.d.ts +2 -0
- package/dist/workflow/tool.d.ts +8 -0
- package/dist/workflow/types.d.ts +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
**Web-standard HTTP framework for Node.js.** `(req, ctx) => Response` — no framework-specific objects, just the Web API your browser already speaks.
|
|
4
4
|
|
|
5
|
+
### Design
|
|
6
|
+
|
|
7
|
+
weifuwu doesn't invent its own request/response abstraction. `Request` and `Response` are the same objects you use in `fetch()` — what you learn in the browser applies directly on the server. `ctx` is the only framework object, and it only carries what the router parsed for you (`params`, `query`).
|
|
8
|
+
|
|
9
|
+
Features like `tsx()`, WebSocket, GraphQL, and AI streaming all follow the same `(req, ctx) => Response` contract. There is no separate concept for "page route" vs "API route" — everything is a handler that returns a `Response`. `tsx()` just generates a `Response` from a React component the same way `router.get()` returns a `Response` from a handler function.
|
|
10
|
+
|
|
5
11
|
## Features
|
|
6
12
|
|
|
7
13
|
- **Web Standard** — `Request` / `Response` / `ReadableStream`, zero abstractions
|
|
8
14
|
- **Trie router** — static > param > wildcard, sub-router mounting, path params
|
|
9
15
|
- **Middleware** — global, path-scoped, route-level — onion model, short-circuit
|
|
10
16
|
- **Built-in middleware** — `auth()`, `cors()`, `logger()`, `rateLimit()`, `compress()`
|
|
11
|
-
- **React SSR + Hydration** — `tsx({ dir })` — page.tsx / load.ts / layout.tsx / route.ts
|
|
17
|
+
- **React SSR + Hydration** — `tsx({ dir })` — page.tsx / load.ts / layout.tsx / route.ts / not-found.tsx
|
|
12
18
|
- **WebSocket** — `router.ws()` with upgrade middleware (auth before connect)
|
|
13
19
|
- **GraphQL** — `router.graphql()` with GraphiQL IDE
|
|
14
20
|
- **AI streaming** — `router.ai()` via Vercel AI SDK
|
|
21
|
+
- **AI workflows** — `router.workflow()` — intent-to-execution pipelines with `tool()` + SSE
|
|
15
22
|
- **Static files** — `serveStatic()` with ETag, 304, MIME, directory index
|
|
16
23
|
- **Request validation** — `validate()` with Zod (body / query / params / headers)
|
|
17
24
|
- **File upload** — `upload()` multipart parser with disk save, size & type limits
|
|
@@ -46,12 +53,15 @@ serve(app.handler(), { port: 3000 })
|
|
|
46
53
|
pages/
|
|
47
54
|
page.tsx → GET / (React component, default export)
|
|
48
55
|
layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
56
|
+
not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
49
57
|
about/page.tsx → GET /about
|
|
50
58
|
blog/[slug]/
|
|
51
59
|
page.tsx → GET /blog/:slug
|
|
52
60
|
load.ts → data fetching (server-only, default export)
|
|
53
|
-
route.ts → POST /blog/:slug (API, named exports
|
|
61
|
+
route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
54
62
|
blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
63
|
+
api/search/
|
|
64
|
+
route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
55
65
|
```
|
|
56
66
|
|
|
57
67
|
### page.tsx — page component
|
|
@@ -155,7 +165,18 @@ export const POST: Handler = async (req, ctx) => {
|
|
|
155
165
|
}
|
|
156
166
|
```
|
|
157
167
|
|
|
158
|
-
Route.ts exports `POST`/`PUT`/`DELETE`/`PATCH` (GET is handled by page.tsx). The same `route.ts` file coexists with `page.tsx` in the same directory for handling form submissions or AJAX requests.
|
|
168
|
+
Route.ts exports `POST`/`PUT`/`DELETE`/`PATCH` (GET is handled by page.tsx). The same `route.ts` file coexists with `page.tsx` in the same directory for handling form submissions or AJAX requests. Standalone `route.ts` (without a co-located `page.tsx`) registers all methods including `GET`.
|
|
169
|
+
|
|
170
|
+
### not-found.tsx — 404 page
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
// pages/not-found.tsx
|
|
174
|
+
export default function NotFound() {
|
|
175
|
+
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Automatically rendered for unmatched routes, wrapped in the full layout chain. Works with `use('/')` mounting and standalone usage.
|
|
159
180
|
|
|
160
181
|
### Usage within a full app
|
|
161
182
|
|
|
@@ -224,6 +245,9 @@ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
|
224
245
|
|
|
225
246
|
// Custom header
|
|
226
247
|
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
248
|
+
|
|
249
|
+
// Token can also be passed via query string ?access_token=xxx
|
|
250
|
+
// Proxy forwards using the same method the client used (header ↔ query)
|
|
227
251
|
```
|
|
228
252
|
|
|
229
253
|
### CORS
|
|
@@ -407,6 +431,35 @@ const app = new Router()
|
|
|
407
431
|
serve(app.handler(), { port: 3000 })
|
|
408
432
|
```
|
|
409
433
|
|
|
434
|
+
## Workflow
|
|
435
|
+
|
|
436
|
+
Define tools (business capabilities) and let AI generate and execute multi-step workflows.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { Router, tool, createWorkflowEngine } from 'weifuwu'
|
|
440
|
+
import { z } from 'zod'
|
|
441
|
+
|
|
442
|
+
const tools = {
|
|
443
|
+
queryUser: tool({
|
|
444
|
+
description: '查询用户信息,返回 email, name',
|
|
445
|
+
inputSchema: z.object({ userId: z.string() }),
|
|
446
|
+
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
447
|
+
}),
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const app = new Router()
|
|
451
|
+
|
|
452
|
+
// Router method — accepts { nodes } or { goal } with model
|
|
453
|
+
app.workflow('/api/agent', { tools })
|
|
454
|
+
|
|
455
|
+
// Or manual execution
|
|
456
|
+
const engine = createWorkflowEngine({ tools })
|
|
457
|
+
const result = await engine.execute({ nodes: [
|
|
458
|
+
{ id: 's1', tool: 'set', input: { name: 'msg', value: 'hello' } },
|
|
459
|
+
{ id: 'g1', tool: 'get', input: { name: 'msg' } },
|
|
460
|
+
]})
|
|
461
|
+
```
|
|
462
|
+
|
|
410
463
|
## Graceful shutdown
|
|
411
464
|
|
|
412
465
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -19,3 +19,5 @@ export { rateLimit } from './rate-limit.ts';
|
|
|
19
19
|
export type { RateLimitOptions } from './rate-limit.ts';
|
|
20
20
|
export { compress } from './compress.ts';
|
|
21
21
|
export type { CompressOptions } from './compress.ts';
|
|
22
|
+
export { tool, createWorkflowEngine, createSSEManager, generateWorkflow } from './workflow/index.ts';
|
|
23
|
+
export type { Tool, Workflow, WorkflowEngine, WorkflowState, SSEEvent } from './workflow/types.ts';
|
package/dist/index.js
CHANGED
|
@@ -110,6 +110,490 @@ import { WebSocketServer } from "ws";
|
|
|
110
110
|
import { buildSchema, graphql } from "graphql";
|
|
111
111
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
|
112
112
|
import { streamText } from "ai";
|
|
113
|
+
|
|
114
|
+
// workflow/tool.ts
|
|
115
|
+
function tool(def) {
|
|
116
|
+
return {
|
|
117
|
+
name: def.name ?? "",
|
|
118
|
+
description: def.description,
|
|
119
|
+
inputSchema: def.inputSchema,
|
|
120
|
+
execute: def.execute
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// workflow/reference.ts
|
|
125
|
+
function getByPath(obj, path) {
|
|
126
|
+
let current = obj;
|
|
127
|
+
for (const key of path) {
|
|
128
|
+
if (current === null || current === void 0) return void 0;
|
|
129
|
+
if (typeof current === "object" && key in current) {
|
|
130
|
+
current = current[key];
|
|
131
|
+
} else {
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return current;
|
|
136
|
+
}
|
|
137
|
+
function resolveRef(path, ctx) {
|
|
138
|
+
if (path.startsWith("$nodes.")) {
|
|
139
|
+
const afterNodes = path.slice(7);
|
|
140
|
+
const dotIdx = afterNodes.indexOf(".");
|
|
141
|
+
if (dotIdx === -1) {
|
|
142
|
+
return ctx.nodeOutputs.get(afterNodes);
|
|
143
|
+
}
|
|
144
|
+
const id2 = afterNodes.slice(0, dotIdx);
|
|
145
|
+
const propPath = afterNodes.slice(dotIdx + 1);
|
|
146
|
+
const output = ctx.nodeOutputs.get(id2);
|
|
147
|
+
if (output === void 0) {
|
|
148
|
+
throw new Error(`Node "${id2}" has no output yet`);
|
|
149
|
+
}
|
|
150
|
+
if (propPath.startsWith("output")) {
|
|
151
|
+
return getByPath(output, propPath.slice(7).split(".").filter(Boolean));
|
|
152
|
+
}
|
|
153
|
+
return getByPath(output, propPath.split("."));
|
|
154
|
+
}
|
|
155
|
+
if (path.startsWith("$var.")) {
|
|
156
|
+
const name = path.slice(5);
|
|
157
|
+
if (!ctx.variables.has(name)) {
|
|
158
|
+
throw new Error(`Variable "${name}" is not defined`);
|
|
159
|
+
}
|
|
160
|
+
return ctx.variables.get(name);
|
|
161
|
+
}
|
|
162
|
+
if (path.startsWith("$input.")) {
|
|
163
|
+
const key = path.slice(7);
|
|
164
|
+
return ctx.input[key];
|
|
165
|
+
}
|
|
166
|
+
if (path === "true") return true;
|
|
167
|
+
if (path === "false") return false;
|
|
168
|
+
if (path === "null") return null;
|
|
169
|
+
const num = Number(path);
|
|
170
|
+
if (!isNaN(num) && path.trim() !== "") return num;
|
|
171
|
+
return path;
|
|
172
|
+
}
|
|
173
|
+
function resolveValue(v, ctx) {
|
|
174
|
+
if (typeof v === "string" && v.startsWith("$")) {
|
|
175
|
+
return resolveRef(v, ctx);
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(v)) {
|
|
178
|
+
return v.map((item) => resolveValue(item, ctx));
|
|
179
|
+
}
|
|
180
|
+
if (typeof v === "object" && v !== null) {
|
|
181
|
+
const result = {};
|
|
182
|
+
for (const [k, val] of Object.entries(v)) {
|
|
183
|
+
result[k] = resolveValue(val, ctx);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
return v;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// workflow/nodes.ts
|
|
191
|
+
function evaluateExpression(expr, ctx) {
|
|
192
|
+
const operators = [
|
|
193
|
+
{ op: "===", fn: (a, b) => a === b },
|
|
194
|
+
{ op: "!==", fn: (a, b) => a !== b },
|
|
195
|
+
{ op: ">=", fn: (a, b) => Number(a) >= Number(b) },
|
|
196
|
+
{ op: "<=", fn: (a, b) => Number(a) <= Number(b) },
|
|
197
|
+
{ op: ">", fn: (a, b) => Number(a) > Number(b) },
|
|
198
|
+
{ op: "<", fn: (a, b) => Number(a) < Number(b) },
|
|
199
|
+
{ op: "==", fn: (a, b) => a == b },
|
|
200
|
+
{ op: "!=", fn: (a, b) => a != b },
|
|
201
|
+
{ op: "+", fn: (a, b) => Number(a) + Number(b) },
|
|
202
|
+
{ op: "-", fn: (a, b) => Number(a) - Number(b) },
|
|
203
|
+
{ op: "*", fn: (a, b) => Number(a) * Number(b) },
|
|
204
|
+
{ op: "/", fn: (a, b) => Number(a) / Number(b) },
|
|
205
|
+
{ op: "%", fn: (a, b) => Number(a) % Number(b) },
|
|
206
|
+
{ op: "&&", fn: (a, b) => Boolean(a) && Boolean(b) },
|
|
207
|
+
{ op: "||", fn: (a, b) => Boolean(a) || Boolean(b) }
|
|
208
|
+
];
|
|
209
|
+
for (const { op, fn } of operators) {
|
|
210
|
+
const idx = expr.indexOf(op);
|
|
211
|
+
if (idx > 0) {
|
|
212
|
+
const leftRaw = expr.slice(0, idx).trim();
|
|
213
|
+
const rightRaw = expr.slice(idx + op.length).trim();
|
|
214
|
+
const left = resolveValue(leftRaw, ctx);
|
|
215
|
+
const right = resolveValue(rightRaw, ctx);
|
|
216
|
+
return fn(left, right);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const trimmed = expr.trim();
|
|
220
|
+
if (trimmed === "true") return true;
|
|
221
|
+
if (trimmed === "false") return false;
|
|
222
|
+
if (trimmed === "null") return null;
|
|
223
|
+
const num = Number(trimmed);
|
|
224
|
+
if (!isNaN(num) && trimmed !== "") return num;
|
|
225
|
+
return resolveValue(expr, ctx);
|
|
226
|
+
}
|
|
227
|
+
async function executeEval(node, ctx) {
|
|
228
|
+
const expression = node.input.expression;
|
|
229
|
+
if (!expression) throw new Error('eval node requires "expression" field');
|
|
230
|
+
const result = evaluateExpression(expression, ctx);
|
|
231
|
+
return { result };
|
|
232
|
+
}
|
|
233
|
+
async function executeSet(node, ctx) {
|
|
234
|
+
const name = node.input.name;
|
|
235
|
+
const value = node.input.value;
|
|
236
|
+
if (!name) throw new Error('set node requires "name" field');
|
|
237
|
+
let resolved;
|
|
238
|
+
if (typeof value === "string") {
|
|
239
|
+
resolved = evaluateExpression(value, ctx);
|
|
240
|
+
} else {
|
|
241
|
+
resolved = resolveValue(value ?? null, ctx);
|
|
242
|
+
}
|
|
243
|
+
ctx.variables.set(name, resolved);
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
async function executeGet(node, ctx) {
|
|
247
|
+
const name = node.input.name;
|
|
248
|
+
if (!name) throw new Error('get node requires "name" field');
|
|
249
|
+
if (!ctx.variables.has(name)) {
|
|
250
|
+
throw new Error(`Variable "${name}" is not defined`);
|
|
251
|
+
}
|
|
252
|
+
return ctx.variables.get(name);
|
|
253
|
+
}
|
|
254
|
+
async function executeIf(node, ctx) {
|
|
255
|
+
const conditions = node.conditions ?? [];
|
|
256
|
+
for (const condition of conditions) {
|
|
257
|
+
const test = typeof condition.test === "string" ? Boolean(resolveValue(condition.test, ctx)) : condition.test;
|
|
258
|
+
if (test && condition.body) {
|
|
259
|
+
let lastOutput = void 0;
|
|
260
|
+
for (const bodyNode of condition.body) {
|
|
261
|
+
lastOutput = await executeNode(bodyNode, ctx);
|
|
262
|
+
}
|
|
263
|
+
return lastOutput;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return void 0;
|
|
267
|
+
}
|
|
268
|
+
async function executeWhile(node, ctx) {
|
|
269
|
+
const conditionExpr = node.input.condition;
|
|
270
|
+
if (!conditionExpr) throw new Error('while node requires "condition" field');
|
|
271
|
+
let lastOutput = void 0;
|
|
272
|
+
let iterations = 0;
|
|
273
|
+
const maxIterations = 1e3;
|
|
274
|
+
while (iterations < maxIterations) {
|
|
275
|
+
iterations++;
|
|
276
|
+
ctx.stepCount++;
|
|
277
|
+
if (ctx.stepCount > ctx.maxSteps) {
|
|
278
|
+
throw new Error(`Step limit exceeded (${ctx.maxSteps})`);
|
|
279
|
+
}
|
|
280
|
+
const condition = Boolean(evaluateExpression(conditionExpr, ctx));
|
|
281
|
+
if (!condition) break;
|
|
282
|
+
for (const bodyNode of node.body ?? []) {
|
|
283
|
+
lastOutput = await executeNode(bodyNode, ctx);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return lastOutput;
|
|
287
|
+
}
|
|
288
|
+
async function executeCall(node, ctx) {
|
|
289
|
+
const toolName = node.input.tool;
|
|
290
|
+
const args = node.input.args ?? {};
|
|
291
|
+
if (toolName && ctx.toolRegistry.has(toolName)) {
|
|
292
|
+
const tool3 = ctx.toolRegistry.get(toolName);
|
|
293
|
+
const resolvedInput = resolveValue(args, ctx);
|
|
294
|
+
const parsed = tool3.inputSchema.parse(resolvedInput);
|
|
295
|
+
return tool3.execute(parsed, {
|
|
296
|
+
nodeId: node.id,
|
|
297
|
+
workflowId: ctx.workflowId,
|
|
298
|
+
onStream: async (event) => {
|
|
299
|
+
if (ctx.sseManager && ctx.workflowId) {
|
|
300
|
+
ctx.sseManager.send(ctx.workflowId, { event: "llm-stream", data: { nodeId: node.id, ...event } });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const functionName = node.input.function;
|
|
306
|
+
if (functionName && ctx.functions[functionName]) {
|
|
307
|
+
const fn = ctx.functions[functionName];
|
|
308
|
+
const prevFunctions = ctx.functions;
|
|
309
|
+
const prevInput = ctx.input;
|
|
310
|
+
ctx.input = resolveValue(args, ctx);
|
|
311
|
+
let lastOutput = void 0;
|
|
312
|
+
for (const bodyNode of fn.workflow.nodes) {
|
|
313
|
+
lastOutput = await executeNode(bodyNode, ctx);
|
|
314
|
+
}
|
|
315
|
+
ctx.input = prevInput;
|
|
316
|
+
return lastOutput;
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`call node: tool "${toolName ?? functionName}" not found`);
|
|
319
|
+
}
|
|
320
|
+
async function executeHttp(node, ctx) {
|
|
321
|
+
const input = resolveValue(node.input, ctx);
|
|
322
|
+
const url = input.url;
|
|
323
|
+
if (!url) throw new Error('http node requires "url" field');
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
const timeout = input.timeout ?? 3e4;
|
|
326
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
327
|
+
try {
|
|
328
|
+
const fetchInit = {
|
|
329
|
+
method: input.method ?? "GET",
|
|
330
|
+
headers: input.headers ?? {},
|
|
331
|
+
signal: controller.signal
|
|
332
|
+
};
|
|
333
|
+
if (input.body && fetchInit.method !== "GET") {
|
|
334
|
+
fetchInit.body = JSON.stringify(input.body);
|
|
335
|
+
}
|
|
336
|
+
const response = await fetch(url, fetchInit);
|
|
337
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
338
|
+
const body = contentType.includes("application/json") ? await response.json() : await response.text();
|
|
339
|
+
return {
|
|
340
|
+
status: response.status,
|
|
341
|
+
statusText: response.statusText,
|
|
342
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
343
|
+
body
|
|
344
|
+
};
|
|
345
|
+
} finally {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
var executors = {
|
|
350
|
+
eval: executeEval,
|
|
351
|
+
set: executeSet,
|
|
352
|
+
get: executeGet,
|
|
353
|
+
if: executeIf,
|
|
354
|
+
while: executeWhile,
|
|
355
|
+
call: executeCall,
|
|
356
|
+
http: executeHttp
|
|
357
|
+
};
|
|
358
|
+
async function executeNode(node, ctx) {
|
|
359
|
+
const executor = executors[node.tool];
|
|
360
|
+
if (!executor) {
|
|
361
|
+
throw new Error(`Unknown node type: "${node.tool}"`);
|
|
362
|
+
}
|
|
363
|
+
return executor(node, ctx);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// workflow/llm.ts
|
|
367
|
+
function buildToolsDescription(tools) {
|
|
368
|
+
return Object.entries(tools).map(([key, t]) => {
|
|
369
|
+
const name = t.name || key;
|
|
370
|
+
const schema = t.inputSchema;
|
|
371
|
+
return `- ${name}: ${t.description}
|
|
372
|
+
Input schema: describe as JSON object fields`;
|
|
373
|
+
}).join("\n");
|
|
374
|
+
}
|
|
375
|
+
var SYSTEM_PROMPT_TEMPLATE = `You are a workflow generator. Given a user goal and available tools, output a workflow JSON.
|
|
376
|
+
|
|
377
|
+
Available tools:
|
|
378
|
+
{{TOOLS}}
|
|
379
|
+
|
|
380
|
+
Workflow format:
|
|
381
|
+
{
|
|
382
|
+
"name": "workflow name",
|
|
383
|
+
"nodes": [
|
|
384
|
+
{
|
|
385
|
+
"id": "step1",
|
|
386
|
+
"tool": "set",
|
|
387
|
+
"input": { "name": "varName", "value": "initialValue" }
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
"id": "step2",
|
|
391
|
+
"tool": "call",
|
|
392
|
+
"input": { "tool": "toolName", "args": { "param1": "$var.varName" } }
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
"id": "step3",
|
|
396
|
+
"tool": "if",
|
|
397
|
+
"input": {},
|
|
398
|
+
"conditions": [
|
|
399
|
+
{ "test": "$nodes.step2.output.someField", "body": [
|
|
400
|
+
{ "id": "step4", "tool": "call", "input": { "tool": "toolName", "args": {} } }
|
|
401
|
+
]}
|
|
402
|
+
]
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Node types:
|
|
408
|
+
- eval: evaluate an expression. input: { expression: "..." }
|
|
409
|
+
- set: assign a variable. input: { name, value }
|
|
410
|
+
- get: read a variable. input: { name }
|
|
411
|
+
- if: conditional branch. input: {}, conditions: [{ test, body }]
|
|
412
|
+
- while: loop. input: { condition }, body: [nodes]
|
|
413
|
+
- call: call a registered tool. input: { tool, args }
|
|
414
|
+
- http: HTTP request. input: { url, method?, headers?, body? }
|
|
415
|
+
|
|
416
|
+
Reference syntax:
|
|
417
|
+
- $var.name - read a variable
|
|
418
|
+
- $nodes.id.output - output of a previous node
|
|
419
|
+
- $nodes.id.output.field - specific field of a node's output
|
|
420
|
+
- $input.field - workflow input parameter
|
|
421
|
+
|
|
422
|
+
Output ONLY valid JSON. No explanation, no markdown.`;
|
|
423
|
+
async function generateWorkflow(goal, tools, generateFn) {
|
|
424
|
+
const toolsDesc = buildToolsDescription(tools);
|
|
425
|
+
const system = SYSTEM_PROMPT_TEMPLATE.replace("{{TOOLS}}", toolsDesc);
|
|
426
|
+
const result = await generateFn({
|
|
427
|
+
system,
|
|
428
|
+
messages: [{ role: "user", content: goal }]
|
|
429
|
+
});
|
|
430
|
+
const text = result.text.trim();
|
|
431
|
+
const jsonStart = text.indexOf("{");
|
|
432
|
+
const jsonEnd = text.lastIndexOf("}");
|
|
433
|
+
if (jsonStart === -1 || jsonEnd === -1) {
|
|
434
|
+
throw new Error(`LLM output is not valid JSON: ${text.slice(0, 200)}`);
|
|
435
|
+
}
|
|
436
|
+
const jsonStr = text.slice(jsonStart, jsonEnd + 1);
|
|
437
|
+
try {
|
|
438
|
+
const workflow = JSON.parse(jsonStr);
|
|
439
|
+
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
|
|
440
|
+
throw new Error("Generated workflow has no nodes array");
|
|
441
|
+
}
|
|
442
|
+
return workflow;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (err instanceof SyntaxError) {
|
|
445
|
+
throw new Error(`Failed to parse LLM output as JSON: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// workflow/engine.ts
|
|
452
|
+
import { generateText } from "ai";
|
|
453
|
+
function createWorkflowEngine(options) {
|
|
454
|
+
const toolRegistry = /* @__PURE__ */ new Map();
|
|
455
|
+
for (const [key, t] of Object.entries(options.tools)) {
|
|
456
|
+
t.name = t.name || key;
|
|
457
|
+
toolRegistry.set(t.name, t);
|
|
458
|
+
}
|
|
459
|
+
const states = /* @__PURE__ */ new Map();
|
|
460
|
+
async function execute(workflow, opts) {
|
|
461
|
+
const ctx = {
|
|
462
|
+
variables: /* @__PURE__ */ new Map(),
|
|
463
|
+
nodeOutputs: /* @__PURE__ */ new Map(),
|
|
464
|
+
functions: workflow.functions ?? {},
|
|
465
|
+
stepCount: 0,
|
|
466
|
+
maxSteps: opts?.maxSteps ?? 1e3,
|
|
467
|
+
input: opts?.initialInput ?? {},
|
|
468
|
+
toolRegistry,
|
|
469
|
+
sseManager: options.sseManager,
|
|
470
|
+
workflowId: opts?.workflowId
|
|
471
|
+
};
|
|
472
|
+
let lastOutput = void 0;
|
|
473
|
+
for (const node of workflow.nodes) {
|
|
474
|
+
ctx.stepCount++;
|
|
475
|
+
if (ctx.stepCount > ctx.maxSteps) {
|
|
476
|
+
throw new Error(`Step limit exceeded (${ctx.maxSteps})`);
|
|
477
|
+
}
|
|
478
|
+
options.sseManager?.send(ctx.workflowId ?? "", { event: "node-start", data: { nodeId: node.id, tool: node.tool, input: node.input } });
|
|
479
|
+
const output = await executeNode(node, ctx);
|
|
480
|
+
ctx.nodeOutputs.set(node.id, output);
|
|
481
|
+
lastOutput = output;
|
|
482
|
+
options.sseManager?.send(ctx.workflowId ?? "", { event: "node-end", data: { nodeId: node.id, output } });
|
|
483
|
+
}
|
|
484
|
+
return lastOutput;
|
|
485
|
+
}
|
|
486
|
+
async function runAsync(workflowId, workflow, opts) {
|
|
487
|
+
const state = {
|
|
488
|
+
workflowId,
|
|
489
|
+
status: "running",
|
|
490
|
+
goal: workflow.name ?? "",
|
|
491
|
+
startTime: Date.now()
|
|
492
|
+
};
|
|
493
|
+
states.set(workflowId, state);
|
|
494
|
+
const sse = options.sseManager;
|
|
495
|
+
sse?.send(workflowId, { event: "workflow-start", data: { workflowId, goal: state.goal } });
|
|
496
|
+
try {
|
|
497
|
+
const result = await execute(workflow, { ...opts, workflowId });
|
|
498
|
+
state.status = "completed";
|
|
499
|
+
state.result = result;
|
|
500
|
+
state.endTime = Date.now();
|
|
501
|
+
sse?.send(workflowId, { event: "complete", data: { result, duration: state.endTime - state.startTime } });
|
|
502
|
+
} catch (err) {
|
|
503
|
+
state.status = "error";
|
|
504
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
505
|
+
state.endTime = Date.now();
|
|
506
|
+
sse?.send(workflowId, { event: "error", data: { error: state.error } });
|
|
507
|
+
} finally {
|
|
508
|
+
sse?.close(workflowId);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function generateWorkflow2(goal) {
|
|
512
|
+
if (!options.model) {
|
|
513
|
+
throw new Error('LLM model is required for generateWorkflow. Pass "model" to createWorkflowEngine.');
|
|
514
|
+
}
|
|
515
|
+
return generateWorkflow(goal, options.tools, async (prompt) => {
|
|
516
|
+
const result = await generateText({
|
|
517
|
+
model: options.model,
|
|
518
|
+
system: prompt.system,
|
|
519
|
+
messages: prompt.messages
|
|
520
|
+
});
|
|
521
|
+
return { text: result.text };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
execute,
|
|
526
|
+
runAsync,
|
|
527
|
+
generateWorkflow: generateWorkflow2,
|
|
528
|
+
getState(workflowId) {
|
|
529
|
+
return states.get(workflowId);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// workflow/sse.ts
|
|
535
|
+
function createSSEManager() {
|
|
536
|
+
const streams = /* @__PURE__ */ new Map();
|
|
537
|
+
const encoder = new TextEncoder();
|
|
538
|
+
function createStream(workflowId) {
|
|
539
|
+
const state = {
|
|
540
|
+
controller: null,
|
|
541
|
+
encoder,
|
|
542
|
+
closed: false,
|
|
543
|
+
buffer: []
|
|
544
|
+
};
|
|
545
|
+
const stream = new ReadableStream({
|
|
546
|
+
start(controller) {
|
|
547
|
+
state.controller = controller;
|
|
548
|
+
streams.set(workflowId, state);
|
|
549
|
+
for (const event of state.buffer) {
|
|
550
|
+
try {
|
|
551
|
+
controller.enqueue(encoder.encode(event));
|
|
552
|
+
} catch {
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
state.buffer = [];
|
|
557
|
+
},
|
|
558
|
+
cancel() {
|
|
559
|
+
state.closed = true;
|
|
560
|
+
streams.delete(workflowId);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
return stream;
|
|
564
|
+
}
|
|
565
|
+
function send(workflowId, event) {
|
|
566
|
+
const state = streams.get(workflowId);
|
|
567
|
+
if (!state || state.closed) return;
|
|
568
|
+
const data = `event: ${event.event}
|
|
569
|
+
data: ${JSON.stringify(event.data)}
|
|
570
|
+
|
|
571
|
+
`;
|
|
572
|
+
if (state.controller) {
|
|
573
|
+
try {
|
|
574
|
+
state.controller.enqueue(encoder.encode(data));
|
|
575
|
+
} catch {
|
|
576
|
+
state.closed = true;
|
|
577
|
+
streams.delete(workflowId);
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
state.buffer.push(data);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function close(workflowId) {
|
|
584
|
+
const state = streams.get(workflowId);
|
|
585
|
+
if (!state) return;
|
|
586
|
+
state.closed = true;
|
|
587
|
+
streams.delete(workflowId);
|
|
588
|
+
try {
|
|
589
|
+
state.controller?.close();
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { createStream, send, close };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// router.ts
|
|
113
597
|
var createTrieNode = () => ({
|
|
114
598
|
children: /* @__PURE__ */ new Map(),
|
|
115
599
|
handlers: /* @__PURE__ */ new Map(),
|
|
@@ -302,6 +786,50 @@ var Router = class _Router {
|
|
|
302
786
|
};
|
|
303
787
|
return this.post(path, ...middlewares, routeHandler);
|
|
304
788
|
}
|
|
789
|
+
workflow(path, options) {
|
|
790
|
+
const sseManager = options.stream ? createSSEManager() : void 0;
|
|
791
|
+
if (options.stream && sseManager) {
|
|
792
|
+
this.get(`${path}/:workflowId/events`, async (req, ctx) => {
|
|
793
|
+
const stream = sseManager.createStream(ctx.params.workflowId);
|
|
794
|
+
return new Response(stream, {
|
|
795
|
+
headers: {
|
|
796
|
+
"Content-Type": "text/event-stream",
|
|
797
|
+
"Cache-Control": "no-cache",
|
|
798
|
+
"Connection": "keep-alive"
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
this.post(path, async (req) => {
|
|
804
|
+
const body = await req.json();
|
|
805
|
+
const engine = createWorkflowEngine({
|
|
806
|
+
tools: options.tools,
|
|
807
|
+
model: options.model,
|
|
808
|
+
sseManager
|
|
809
|
+
});
|
|
810
|
+
let wf;
|
|
811
|
+
if (body.goal && options.model) {
|
|
812
|
+
wf = await engine.generateWorkflow(body.goal);
|
|
813
|
+
} else if (body.workflow) {
|
|
814
|
+
wf = body.workflow;
|
|
815
|
+
} else if (body.nodes) {
|
|
816
|
+
wf = { nodes: body.nodes };
|
|
817
|
+
} else {
|
|
818
|
+
return Response.json(
|
|
819
|
+
{ error: 'Provide "goal" (with model) or "workflow"/"nodes"' },
|
|
820
|
+
{ status: 400 }
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (options.stream && sseManager) {
|
|
824
|
+
const workflowId = crypto.randomUUID();
|
|
825
|
+
engine.runAsync(workflowId, wf);
|
|
826
|
+
return Response.json({ workflowId, eventsUrl: `${path}/${workflowId}/events` });
|
|
827
|
+
}
|
|
828
|
+
const result = await engine.execute(wf);
|
|
829
|
+
return Response.json({ workflow: wf, result });
|
|
830
|
+
});
|
|
831
|
+
return this;
|
|
832
|
+
}
|
|
305
833
|
handler() {
|
|
306
834
|
return (req, ctx) => {
|
|
307
835
|
const url = new URL(req.url);
|
|
@@ -1472,13 +2000,17 @@ export {
|
|
|
1472
2000
|
auth,
|
|
1473
2001
|
compress,
|
|
1474
2002
|
cors,
|
|
2003
|
+
createSSEManager,
|
|
2004
|
+
createWorkflowEngine,
|
|
1475
2005
|
deleteCookie,
|
|
2006
|
+
generateWorkflow,
|
|
1476
2007
|
getCookies,
|
|
1477
2008
|
logger,
|
|
1478
2009
|
rateLimit,
|
|
1479
2010
|
serve,
|
|
1480
2011
|
serveStatic,
|
|
1481
2012
|
setCookie,
|
|
2013
|
+
tool,
|
|
1482
2014
|
tsx,
|
|
1483
2015
|
upload,
|
|
1484
2016
|
useTsx,
|
package/dist/router.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { type WebSocket } from 'ws';
|
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
3
|
import type { Duplex } from 'node:stream';
|
|
4
4
|
import { type GraphQLSchema } from 'graphql';
|
|
5
|
-
import { streamText } from 'ai';
|
|
5
|
+
import { streamText, generateText } from 'ai';
|
|
6
6
|
import type { Context, Handler, Middleware, ErrorHandler } from './types.ts';
|
|
7
|
+
import type { Tool as WfTool } from './workflow/types.ts';
|
|
7
8
|
type StreamTextParams = Parameters<typeof streamText>[0];
|
|
8
9
|
export type WebSocketHandler = {
|
|
9
10
|
open?: (ws: WebSocket, ctx: Context) => void | Promise<void>;
|
|
@@ -44,6 +45,11 @@ export declare class Router {
|
|
|
44
45
|
ws(path: string, ...args: [...Middleware[], WebSocketHandler]): this;
|
|
45
46
|
graphql(path: string, ...args: [...Middleware[], GraphQLOptions]): this;
|
|
46
47
|
ai(path: string, ...args: [...Middleware[], AIHandler]): this;
|
|
48
|
+
workflow(path: string, options: {
|
|
49
|
+
tools: Record<string, WfTool>;
|
|
50
|
+
model?: Parameters<typeof generateText>[0]['model'];
|
|
51
|
+
stream?: boolean;
|
|
52
|
+
}): this;
|
|
47
53
|
handler(): Handler;
|
|
48
54
|
websocketHandler(): WsUpgradeHandler;
|
|
49
55
|
private splitPath;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Tool, WorkflowEngine, SSEManager } from './types.ts';
|
|
2
|
+
import { type LanguageModel } from 'ai';
|
|
3
|
+
export declare function createWorkflowEngine(options: {
|
|
4
|
+
tools: Record<string, Tool>;
|
|
5
|
+
sseManager?: SSEManager;
|
|
6
|
+
model?: LanguageModel;
|
|
7
|
+
}): WorkflowEngine;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { tool } from './tool.ts';
|
|
2
|
+
export { createWorkflowEngine } from './engine.ts';
|
|
3
|
+
export { createSSEManager } from './sse.ts';
|
|
4
|
+
export { resolveRef, resolveValue } from './reference.ts';
|
|
5
|
+
export { generateWorkflow } from './llm.ts';
|
|
6
|
+
export type { Tool, ToolContext, Workflow, Node, WorkflowState, SSEEvent, SSEManager, WorkflowEngine, ExecuteOptions, StreamEvent, Condition, SubWorkflow } from './types.ts';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Tool, Workflow } from './types.ts';
|
|
2
|
+
export declare function generateWorkflow(goal: string, tools: Record<string, Tool>, generateFn: (prompt: {
|
|
3
|
+
system: string;
|
|
4
|
+
messages: {
|
|
5
|
+
role: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}[];
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
text: string;
|
|
10
|
+
}>): Promise<Workflow>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Node, WorkflowContext } from './types.ts';
|
|
2
|
+
export type NodeExecutor = (node: Node, ctx: WorkflowContext) => Promise<unknown>;
|
|
3
|
+
export declare function executeEval(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
4
|
+
export declare function executeSet(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
5
|
+
export declare function executeGet(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
6
|
+
export declare function executeIf(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
7
|
+
export declare function executeWhile(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
8
|
+
export declare function executeCall(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
9
|
+
export declare function executeHttp(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
10
|
+
export declare function executeNode(node: Node, ctx: WorkflowContext): Promise<unknown>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { Tool, ToolContext } from './types.ts';
|
|
3
|
+
export declare function tool<TInput = unknown, TOutput = unknown>(def: {
|
|
4
|
+
name?: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: z.ZodSchema<TInput>;
|
|
7
|
+
execute: (input: TInput, ctx: ToolContext) => Promise<TOutput>;
|
|
8
|
+
}): Tool<TInput, TOutput>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
export type NodeType = 'eval' | 'set' | 'get' | 'if' | 'while' | 'call' | 'http';
|
|
3
|
+
export interface Tool<TInput = unknown, TOutput = unknown> {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: z.ZodSchema<TInput>;
|
|
7
|
+
execute: (input: TInput, ctx: ToolContext) => Promise<TOutput>;
|
|
8
|
+
}
|
|
9
|
+
export interface ToolContext {
|
|
10
|
+
workflowId?: string;
|
|
11
|
+
nodeId: string;
|
|
12
|
+
onStream?: (event: StreamEvent) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface StreamEvent {
|
|
15
|
+
type: string;
|
|
16
|
+
chunk?: string;
|
|
17
|
+
accumulated?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface Node {
|
|
21
|
+
id: string;
|
|
22
|
+
tool: NodeType | string;
|
|
23
|
+
input: Record<string, unknown>;
|
|
24
|
+
conditions?: Condition[];
|
|
25
|
+
body?: Node[];
|
|
26
|
+
}
|
|
27
|
+
export interface Condition {
|
|
28
|
+
test: string | boolean;
|
|
29
|
+
body: Node[];
|
|
30
|
+
}
|
|
31
|
+
export interface Workflow {
|
|
32
|
+
name?: string;
|
|
33
|
+
nodes: Node[];
|
|
34
|
+
functions?: Record<string, SubWorkflow>;
|
|
35
|
+
}
|
|
36
|
+
export interface SubWorkflow {
|
|
37
|
+
inputSchema: Record<string, unknown>;
|
|
38
|
+
workflow: {
|
|
39
|
+
nodes: Node[];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export interface WorkflowState {
|
|
43
|
+
workflowId: string;
|
|
44
|
+
status: 'running' | 'completed' | 'error';
|
|
45
|
+
goal: string;
|
|
46
|
+
result?: unknown;
|
|
47
|
+
error?: string;
|
|
48
|
+
startTime: number;
|
|
49
|
+
endTime?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface SSEEvent {
|
|
52
|
+
event: string;
|
|
53
|
+
data: unknown;
|
|
54
|
+
}
|
|
55
|
+
export interface ExecuteOptions {
|
|
56
|
+
initialInput?: Record<string, unknown>;
|
|
57
|
+
maxSteps?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface WorkflowContext {
|
|
60
|
+
variables: Map<string, unknown>;
|
|
61
|
+
nodeOutputs: Map<string, unknown>;
|
|
62
|
+
functions: Record<string, SubWorkflow>;
|
|
63
|
+
stepCount: number;
|
|
64
|
+
maxSteps: number;
|
|
65
|
+
input: Record<string, unknown>;
|
|
66
|
+
toolRegistry: Map<string, Tool>;
|
|
67
|
+
sseManager?: SSEManager;
|
|
68
|
+
workflowId?: string;
|
|
69
|
+
onNodeEvent?: (event: SSEEvent) => void;
|
|
70
|
+
}
|
|
71
|
+
export interface SSEManager {
|
|
72
|
+
createStream: (workflowId: string) => ReadableStream<Uint8Array>;
|
|
73
|
+
send: (workflowId: string, event: SSEEvent) => void;
|
|
74
|
+
close: (workflowId: string) => void;
|
|
75
|
+
}
|
|
76
|
+
export interface EngineOptions {
|
|
77
|
+
tools: Record<string, Tool>;
|
|
78
|
+
model?: unknown;
|
|
79
|
+
sseManager?: SSEManager;
|
|
80
|
+
}
|
|
81
|
+
export interface WorkflowEngine {
|
|
82
|
+
execute: (workflow: Workflow, options?: ExecuteOptions) => Promise<unknown>;
|
|
83
|
+
getState: (workflowId: string) => WorkflowState | undefined;
|
|
84
|
+
runAsync: (workflowId: string, workflow: Workflow, options?: ExecuteOptions) => Promise<void>;
|
|
85
|
+
generateWorkflow: (goal: string) => Promise<Workflow>;
|
|
86
|
+
}
|