@zerowidth/workbench-sdk 2.0.0 → 2.1.2
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/nodes/firecrawl-search/firecrawl-search.config.json +46 -0
- package/nodes/firecrawl-search/firecrawl-search.process.js +54 -0
- package/nodes/firecrawl-search/firecrawl-search.tests.json +9 -0
- package/nodes/search-internet/search-internet.config.json +7 -7
- package/package.json +1 -1
- package/src/index.js +118 -29
- package/src/integrations/firecrawl.js +71 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_name": "Firecrawl Search",
|
|
3
|
+
"tagline": "Search the web with Firecrawl",
|
|
4
|
+
"description": "Search the web using the Firecrawl Search API - returns clean search results with titles, links, and snippets",
|
|
5
|
+
"category": "third-party",
|
|
6
|
+
"provider": "firecrawl",
|
|
7
|
+
"needs_key_from": ["firecrawl"],
|
|
8
|
+
"is_plugin": true,
|
|
9
|
+
"inputs": [
|
|
10
|
+
{
|
|
11
|
+
"name": "query",
|
|
12
|
+
"display_name": "Search Query",
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "The search query string",
|
|
15
|
+
"required": true
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "limit",
|
|
19
|
+
"display_name": "Number of Results",
|
|
20
|
+
"type": "number",
|
|
21
|
+
"description": "Maximum number of search results to return (1-100)",
|
|
22
|
+
"default": 5
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"outputs": [
|
|
26
|
+
{
|
|
27
|
+
"name": "items",
|
|
28
|
+
"display_name": "Search Results",
|
|
29
|
+
"type": "array of objects",
|
|
30
|
+
"description": "Array of cleaned search result items with title, link, displayLink, snippet, and position"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "total_results",
|
|
34
|
+
"display_name": "Total Results",
|
|
35
|
+
"type": "number",
|
|
36
|
+
"description": "Number of search results returned"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "warning",
|
|
40
|
+
"display_name": "Warning",
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "Any warning message returned by the Firecrawl API"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"settings": []
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export default async ({inputs, settings, config, nodeConfig}) => {
|
|
2
|
+
// Get Firecrawl integration from engine
|
|
3
|
+
const firecrawl = config.integrations?.firecrawl;
|
|
4
|
+
if (!firecrawl) {
|
|
5
|
+
throw new Error("Firecrawl integration not found");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Build parameters
|
|
9
|
+
const params = {
|
|
10
|
+
query: inputs.query
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Clamp limit into Firecrawl's accepted 1-100 range
|
|
14
|
+
if (inputs.limit !== null && inputs.limit !== undefined && inputs.limit !== '') {
|
|
15
|
+
const limit = Number(inputs.limit);
|
|
16
|
+
if (!Number.isNaN(limit)) {
|
|
17
|
+
params.limit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Make API request - let errors bubble up to be caught by error manager
|
|
22
|
+
const response = await firecrawl.search(params);
|
|
23
|
+
|
|
24
|
+
// Firecrawl v2 keys results by source ({ web: [...], news: [...], images: [...] });
|
|
25
|
+
// tolerate a flat array (v1-style envelope) as well.
|
|
26
|
+
const data = response.data || {};
|
|
27
|
+
const rawResults = Array.isArray(data) ? data : (data.web || []);
|
|
28
|
+
|
|
29
|
+
// Clean up the items to match the shared search-result shape
|
|
30
|
+
// (title / link / displayLink / snippet) used by search nodes.
|
|
31
|
+
const items = rawResults.map((result, index) => {
|
|
32
|
+
let displayLink = null;
|
|
33
|
+
if (result.url) {
|
|
34
|
+
try {
|
|
35
|
+
displayLink = new URL(result.url).hostname;
|
|
36
|
+
} catch {
|
|
37
|
+
displayLink = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
title: result.title || null,
|
|
42
|
+
link: result.url || null,
|
|
43
|
+
displayLink: displayLink,
|
|
44
|
+
snippet: result.description || null,
|
|
45
|
+
position: result.position !== undefined && result.position !== null ? result.position : index + 1
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
items: items,
|
|
51
|
+
total_results: items.length,
|
|
52
|
+
warning: response.warning || null
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"display_name": "Search the Internet",
|
|
3
3
|
"tagline": "Search the internet",
|
|
4
|
-
"description": "Search the web using
|
|
4
|
+
"description": "Search the web using Firecrawl Search - returns clean search results with titles, links, and snippets",
|
|
5
5
|
"category": "third-party",
|
|
6
|
-
"provider": "
|
|
7
|
-
"needs_key_from": ["
|
|
6
|
+
"provider": "firecrawl",
|
|
7
|
+
"needs_key_from": ["firecrawl"],
|
|
8
8
|
"is_macro": true,
|
|
9
9
|
"is_plugin": true,
|
|
10
10
|
"macro_flow": {
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"id": "search",
|
|
21
|
-
"type": "
|
|
21
|
+
"type": "firecrawl-search"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"id": "num_results",
|
|
25
|
-
"type": "
|
|
25
|
+
"type": "number",
|
|
26
26
|
"settings": {
|
|
27
|
-
"value":
|
|
27
|
+
"value": 5
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
{
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"from": { "node_id": "num_results", "port_name": "value" },
|
|
45
|
-
"to": { "node_id": "search", "port_name": "
|
|
45
|
+
"to": { "node_id": "search", "port_name": "limit" }
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"from": { "node_id": "search", "port_name": "items" },
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -20,6 +20,92 @@ import { sanitizeAPICallEvent } from "./utilities/sanitizeAPICall.js";
|
|
|
20
20
|
* Workbench - Core class for executing node-based flows
|
|
21
21
|
* Handles node loading, input/output validation, and flow execution
|
|
22
22
|
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The single `role:"tool"` message for one tool result. MCP-style image
|
|
26
|
+
* content blocks ({type:'image', data, mimeType} with string data) are
|
|
27
|
+
* replaced in place by a short text note — the pixels are delivered to
|
|
28
|
+
* the model separately as an ephemeral vision input (see
|
|
29
|
+
* toolResultToMessages / callLLMWithTools). This same message is used on
|
|
30
|
+
* the wire, in the accumulated multi-round history, and therefore in the
|
|
31
|
+
* final conversation output, so all three carry an identical record with
|
|
32
|
+
* no base64 payload. Image blocks whose data is not a string are left
|
|
33
|
+
* untouched and stringify as before.
|
|
34
|
+
*/
|
|
35
|
+
function toolResultToDurableMessage(toolResult) {
|
|
36
|
+
const result = toolResult.result;
|
|
37
|
+
let durable = result;
|
|
38
|
+
if (result && Array.isArray(result.content)) {
|
|
39
|
+
const hasImage = result.content.some(
|
|
40
|
+
(c) => c && c.type === 'image' && typeof c.data === 'string'
|
|
41
|
+
);
|
|
42
|
+
if (hasImage) {
|
|
43
|
+
durable = {
|
|
44
|
+
...result,
|
|
45
|
+
content: result.content.map((c) =>
|
|
46
|
+
c && c.type === 'image' && typeof c.data === 'string'
|
|
47
|
+
? {
|
|
48
|
+
type: 'text',
|
|
49
|
+
text: `[image (${c.mimeType || 'image/png'}) — delivered to the model as a vision input]`,
|
|
50
|
+
}
|
|
51
|
+
: c
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
role: 'tool',
|
|
58
|
+
tool_call_id: toolResult.tool_call_id,
|
|
59
|
+
name: toolResult.name,
|
|
60
|
+
content: typeof durable === 'string' ? durable : JSON.stringify(durable),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Split one tool result into the wire messages for the next LLM round:
|
|
66
|
+
* the durable `role:"tool"` message plus, when the result carries image
|
|
67
|
+
* content blocks, an ephemeral `role:"user"` vision message with
|
|
68
|
+
* `image_url` data-URI parts. An OpenAI-style tool message is text-only —
|
|
69
|
+
* stringifying image blocks hands the model base64 soup and it
|
|
70
|
+
* confabulates what the image "probably" shows.
|
|
71
|
+
*
|
|
72
|
+
* The caller must insert vision messages ABOVE the entire trailing run of
|
|
73
|
+
* tool-cycle messages, not adjacent to this round's cycle: LLM nodes
|
|
74
|
+
* rebuild their `conversation` output by walking the wire messages
|
|
75
|
+
* backwards and stopping at the first message that is neither a tool
|
|
76
|
+
* result nor an assistant tool_calls turn. Placed above the whole run,
|
|
77
|
+
* a vision message is naturally excluded from the durable output while
|
|
78
|
+
* every tool cycle below it survives the walk.
|
|
79
|
+
*/
|
|
80
|
+
function toolResultToMessages(toolResult) {
|
|
81
|
+
const toolMessage = toolResultToDurableMessage(toolResult);
|
|
82
|
+
const result = toolResult.result;
|
|
83
|
+
let visionMessage = null;
|
|
84
|
+
if (result && Array.isArray(result.content)) {
|
|
85
|
+
const images = result.content.filter(
|
|
86
|
+
(c) => c && c.type === 'image' && typeof c.data === 'string'
|
|
87
|
+
);
|
|
88
|
+
if (images.length > 0) {
|
|
89
|
+
visionMessage = {
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: `[Image${images.length > 1 ? 's' : ''} returned by the ${toolResult.name} tool call (${toolResult.tool_call_id}) — this is the actual content:]`,
|
|
95
|
+
},
|
|
96
|
+
...images.map((img) => ({
|
|
97
|
+
type: 'image_url',
|
|
98
|
+
image_url: {
|
|
99
|
+
url: `data:${img.mimeType || 'image/png'};base64,${img.data}`,
|
|
100
|
+
},
|
|
101
|
+
})),
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { toolMessage, visionMessage };
|
|
107
|
+
}
|
|
108
|
+
|
|
23
109
|
export default class Workbench {
|
|
24
110
|
|
|
25
111
|
|
|
@@ -2171,14 +2257,7 @@ export default class Workbench {
|
|
|
2171
2257
|
if (toolCallMessage && tool_results.length > 0) {
|
|
2172
2258
|
accumulatedMessages = [...accumulatedMessages, toolCallMessage];
|
|
2173
2259
|
for (const toolResult of tool_results) {
|
|
2174
|
-
accumulatedMessages.push(
|
|
2175
|
-
role: "tool",
|
|
2176
|
-
tool_call_id: toolResult.tool_call_id,
|
|
2177
|
-
name: toolResult.name,
|
|
2178
|
-
content: typeof toolResult.result === "string"
|
|
2179
|
-
? toolResult.result
|
|
2180
|
-
: JSON.stringify(toolResult.result)
|
|
2181
|
-
});
|
|
2260
|
+
accumulatedMessages.push(toolResultToDurableMessage(toolResult));
|
|
2182
2261
|
}
|
|
2183
2262
|
}
|
|
2184
2263
|
|
|
@@ -2366,10 +2445,13 @@ export default class Workbench {
|
|
|
2366
2445
|
}
|
|
2367
2446
|
}
|
|
2368
2447
|
}
|
|
2369
|
-
// Prepare the tool call message for the next LLM call
|
|
2448
|
+
// Prepare the tool call message for the next LLM call.
|
|
2449
|
+
// Preserve any text the model emitted alongside the tool calls —
|
|
2450
|
+
// nulling it here loses it from both the next round's context and
|
|
2451
|
+
// the final conversation output.
|
|
2370
2452
|
toolCallMessage = {
|
|
2371
2453
|
role: "assistant",
|
|
2372
|
-
content: null,
|
|
2454
|
+
content: llmResult.content ?? null,
|
|
2373
2455
|
tool_calls: llmResult.tool_calls
|
|
2374
2456
|
};
|
|
2375
2457
|
toolCallCount++;
|
|
@@ -2848,27 +2930,34 @@ export default class Workbench {
|
|
|
2848
2930
|
|
|
2849
2931
|
// If this is a tool call response, append it to the messages array (OpenAI style)
|
|
2850
2932
|
if (toolCallMessage && toolResults && Array.isArray(llmInputs.messages)) {
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
...llmInputs.messages,
|
|
2855
|
-
toolCallMessage
|
|
2856
|
-
];
|
|
2857
|
-
|
|
2858
|
-
for(const toolResult of toolResults) {
|
|
2933
|
+
const toolMessages = [];
|
|
2934
|
+
const visionMessages = [];
|
|
2935
|
+
for (const toolResult of toolResults) {
|
|
2859
2936
|
// You may need to adapt this for other LLMs
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2937
|
+
const { toolMessage, visionMessage } = toolResultToMessages(toolResult);
|
|
2938
|
+
toolMessages.push(toolMessage);
|
|
2939
|
+
if (visionMessage) visionMessages.push(visionMessage);
|
|
2940
|
+
}
|
|
2941
|
+
// Vision messages go ABOVE the entire trailing tool-cycle run, not
|
|
2942
|
+
// just this round's cycle — a vision message wedged between two
|
|
2943
|
+
// cycles would stop the conversation walk early and cut every
|
|
2944
|
+
// earlier cycle out of the final output (see toolResultToMessages).
|
|
2945
|
+
const base = llmInputs.messages;
|
|
2946
|
+
let insertAt = base.length;
|
|
2947
|
+
while (insertAt > 0) {
|
|
2948
|
+
const m = base[insertAt - 1];
|
|
2949
|
+
const inCycle = m && typeof m === 'object' &&
|
|
2950
|
+
(m.role === 'tool' || (Array.isArray(m.tool_calls) && m.tool_calls.length > 0));
|
|
2951
|
+
if (!inCycle) break;
|
|
2952
|
+
insertAt--;
|
|
2871
2953
|
}
|
|
2954
|
+
llmInputs.messages = [
|
|
2955
|
+
...base.slice(0, insertAt),
|
|
2956
|
+
...visionMessages,
|
|
2957
|
+
...base.slice(insertAt),
|
|
2958
|
+
toolCallMessage,
|
|
2959
|
+
...toolMessages
|
|
2960
|
+
];
|
|
2872
2961
|
}
|
|
2873
2962
|
|
|
2874
2963
|
// Execute using the shared core logic
|
|
@@ -82,6 +82,77 @@ export default class FirecrawlIntegration {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Search the web with the Firecrawl Search API
|
|
87
|
+
* @param {Object} params - Search parameters (query, limit, ...)
|
|
88
|
+
* @returns {Promise<Object>} Search response
|
|
89
|
+
*/
|
|
90
|
+
async search(params = {}) {
|
|
91
|
+
try {
|
|
92
|
+
const url = `${this.options.baseURL}/search`;
|
|
93
|
+
|
|
94
|
+
// Remove any params that are null, undefined, or empty strings
|
|
95
|
+
Object.keys(params).forEach(key => {
|
|
96
|
+
if (params[key] === '') {
|
|
97
|
+
delete params[key];
|
|
98
|
+
}
|
|
99
|
+
if (params[key] === null) {
|
|
100
|
+
delete params[key];
|
|
101
|
+
}
|
|
102
|
+
if (params[key] === undefined) {
|
|
103
|
+
delete params[key];
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(params[key]) && params[key].length === 0) {
|
|
106
|
+
delete params[key];
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const requestHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` };
|
|
111
|
+
const startTime = Date.now();
|
|
112
|
+
|
|
113
|
+
const response = await axios({
|
|
114
|
+
url: url,
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: requestHeaders,
|
|
117
|
+
data: params,
|
|
118
|
+
timeout: this.options.timeout
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await emitAPICallEvent(this._engineConfig, {
|
|
122
|
+
timestamp: startTime, integration: 'firecrawl', nodeId: null, nodeType: null,
|
|
123
|
+
request: { method: 'POST', url, headers: requestHeaders, body: params },
|
|
124
|
+
response: { status: response.status, statusText: response.statusText },
|
|
125
|
+
duration: Date.now() - startTime, error: null
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (response.status >= 400) {
|
|
129
|
+
throw new Error(`Firecrawl API error: ${response.status} - ${response.data?.error || response.statusText}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return response.data;
|
|
133
|
+
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Note: emitAPICallEvent for error case is handled by the catch in the caller
|
|
136
|
+
// since errors from axios.post throw before we can capture the response
|
|
137
|
+
if (error.response) {
|
|
138
|
+
const status = error.response.status;
|
|
139
|
+
const statusText = error.response.statusText;
|
|
140
|
+
const responseData = error.response.data;
|
|
141
|
+
|
|
142
|
+
let errorMessage = `Firecrawl API Error (${status} ${statusText})`;
|
|
143
|
+
if (responseData?.error) {
|
|
144
|
+
errorMessage += `: ${responseData.error}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(errorMessage);
|
|
148
|
+
} else if (error.request) {
|
|
149
|
+
throw new Error('Firecrawl API Error: No response received');
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(`Firecrawl API Error: ${error.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
85
156
|
/**
|
|
86
157
|
* Helper function to convert comma-separated string to array
|
|
87
158
|
* @param {string|Array} input - String or array input
|