@burdenoff/fluidgrids-fe-sdk 2026.1111.14 → 2026.1111.16
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 +41 -0
- package/dist/{ExecutionDetailPage-itor_whl.js → ExecutionDetailPage-D-0_POAf.js} +4 -4
- package/dist/{FluidGridsProvider-B30VcLdX.js → FluidGridsProvider-DmvFADFa.js} +20 -309
- package/dist/{HistoryPage-DueEld8g.js → HistoryPage-BbUgxx1I.js} +1 -1
- package/dist/{InsightsPage-DQ7KCR9h.js → InsightsPage-s6f5bU-8.js} +2 -2
- package/dist/{ManagedAiPolicyAuthority-C0YPTJMS.js → ManagedAiPolicyAuthority-DsSaz-vg.js} +2 -2
- package/dist/{MissingNodesInstallModal-Cj31oMDX.js → MissingNodesInstallModal-B-9gHy2K.js} +1 -1
- package/dist/{TemplateConfigurationModal-BAPCG8y-.js → TemplateConfigurationModal-D_Mvgxqa.js} +1 -1
- package/dist/{TemplateDetailPanel-CZ1uxEQL.js → TemplateDetailPanel-DfFfUcOe.js} +1 -1
- package/dist/{TemplatesPage-DWdgZnIH.js → TemplatesPage-Cku13bav.js} +5 -4
- package/dist/{WorkflowSearchRedirect-C8vhSZK9.js → WorkflowSearchRedirect-CaoOOpEl.js} +13 -9
- package/dist/{WorkflowShareDialog-DnBQA3V6.js → WorkflowShareDialog-6RRDe-_v.js} +2 -1
- package/dist/{WorkflowsList-74jjJFsJ.js → WorkflowsList-B2j1Pehb.js} +5 -4
- package/dist/agent-tools.d.ts +3 -0
- package/dist/agent-tools.js +5 -0
- package/dist/{agentTools-D5AW7zHn.js → agentTools-DJ5o2ekg.js} +2 -2
- package/dist/{canvasNoteTools-BqQTTCll.js → canvasNoteTools-D3zUdEeg.js} +3 -2
- package/dist/executions.js +1 -1
- package/dist/{flow-builder-BR8p5tXG.js → flow-builder-DEzT8TQu.js} +34 -157
- package/dist/fluidgridsEvents-CNdcrUNN.js +310 -0
- package/dist/history.js +1 -1
- package/dist/index-CHeY7_A1.d.ts +105 -0
- package/dist/{index-CLqh2u28.d.ts → index-DRf25-D3.d.ts} +7 -105
- package/dist/index.d.ts +3 -2
- package/dist/index.js +21 -946
- package/dist/marksStore-DGCXjTmZ.js +151 -0
- package/dist/{node-detail-view-Dnhqdn8C.js → node-detail-view-LNqeIqN-.js} +3 -3
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +1 -1
- package/dist/{quick-config-panel-C4tSpCrt.js → quick-config-panel-D3ogsaFL.js} +2 -2
- package/dist/settings.js +1 -1
- package/dist/templates.js +1 -1
- package/dist/uiMarkTools-nlmzsHQM.js +937 -0
- package/dist/{useExecutions-CNEN1ENQ.js → useExecutions-DAzeWHS6.js} +2 -1
- package/dist/{useObservability-CmFFXmKb.js → useObservability-COCZ0hUS.js} +2 -1
- package/dist/{useRunHistory-DhU_mrzF.js → useRunHistory-CsYUbouO.js} +1 -1
- package/dist/{useWorkflows-D01LC39Y.js → useWorkflows-w4Dx2fF2.js} +25 -5
- package/dist/workflow-builder.js +2 -2
- package/dist/workflow-route.d.ts +33 -0
- package/dist/workflow-route.js +4 -0
- package/dist/workflowRouteData-BkfVOc-O.js +169 -0
- package/dist/workflows-list.js +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { r as useTr } from "./TranslationBridge-Da-TPgQm.js";
|
|
3
|
+
import { u as nodesOf } from "./fluidgridsEvents-CNdcrUNN.js";
|
|
4
|
+
import { n as useAgentMarksStore } from "./marksStore-DGCXjTmZ.js";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { Sparkles, X } from "lucide-react";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
import { AgentToolError, INTERACTIVE_SELECTOR, accessibleName, assertNoUnsavedChanges, defineAgentTool, isDenied, roleOf } from "@burdenoff/fe-libs/shared/agent-tools";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// src/agentTools/operations.ts
|
|
13
|
+
/**
|
|
14
|
+
* GraphQL operations behind the FluidGrids agent tools (BOFF-9784).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately small selections — an agent gets ~1.5 KB per result, so these
|
|
17
|
+
* ask only for what the tools return. Every document is validated against the
|
|
18
|
+
* composed workspace schema (`graphql/wspace.graphql`) by
|
|
19
|
+
* `operations.test.ts`, so a schema change that breaks one fails CI, not an
|
|
20
|
+
* agent's call.
|
|
21
|
+
*/
|
|
22
|
+
const AGENT_FIND_WORKFLOWS = `
|
|
23
|
+
query AgentFindWorkflows($input: ListWorkflowDefinitionsInput!) {
|
|
24
|
+
workflowDefinitions(input: $input) {
|
|
25
|
+
total
|
|
26
|
+
hasMore
|
|
27
|
+
items { id name workflowKey version status updatedAt workspaceId deletedAt description }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
const AGENT_GET_WORKFLOW = `
|
|
32
|
+
query AgentGetWorkflow($workflowKey: String!, $version: String) {
|
|
33
|
+
workflowDefinition(workflowKey: $workflowKey, version: $version) {
|
|
34
|
+
id name workflowKey version status updatedAt description definitionJson totalVersions workspaceId deletedAt
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
const AGENT_LIST_RUNS = `
|
|
39
|
+
query AgentListRuns($input: ListWorkflowRunsInput!) {
|
|
40
|
+
workflowRuns(input: $input) {
|
|
41
|
+
total
|
|
42
|
+
hasMore
|
|
43
|
+
items { id workflowKey version status mode startedAt completedAt duration errorMessage workflow { name } }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
const AGENT_GET_RUN = `
|
|
48
|
+
query AgentGetRun($id: ID!) {
|
|
49
|
+
workflowRun(id: $id) {
|
|
50
|
+
id workflowKey version status mode startedAt completedAt duration errorMessage errorType
|
|
51
|
+
totalNodes completedNodes failedNodes
|
|
52
|
+
workflow { name }
|
|
53
|
+
nodeStates { nodeId nodeType status error }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
const AGENT_ACTIVATE_WORKFLOW = `
|
|
58
|
+
mutation AgentActivateWorkflow($id: ID!) {
|
|
59
|
+
activateWorkflowDefinition(id: $id) { workflowKey version status }
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
const AGENT_DEACTIVATE_WORKFLOW = `
|
|
63
|
+
mutation AgentDeactivateWorkflow($id: ID!) {
|
|
64
|
+
deactivateWorkflowDefinition(id: $id) { workflowKey version status }
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
const AGENT_TRIGGER_WORKFLOW = `
|
|
68
|
+
mutation AgentTriggerWorkflow($input: TriggerWorkflowInput!) {
|
|
69
|
+
triggerWorkflow(input: $input) { runId status workflowKey version }
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
const AGENT_CANCEL_RUN = `
|
|
73
|
+
mutation AgentCancelRun($id: ID!) {
|
|
74
|
+
cancelWorkflowRun(id: $id) { id status }
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
// src/agentTools/fluidgridsAgentTools.ts
|
|
79
|
+
const WORKFLOW_STATUSES = [
|
|
80
|
+
"ACTIVE",
|
|
81
|
+
"DRAFT",
|
|
82
|
+
"ARCHIVED",
|
|
83
|
+
"DEPRECATED"
|
|
84
|
+
];
|
|
85
|
+
const RUN_STATUSES = [
|
|
86
|
+
"PENDING",
|
|
87
|
+
"RUNNING",
|
|
88
|
+
"WAITING",
|
|
89
|
+
"COMPLETED",
|
|
90
|
+
"FAILED",
|
|
91
|
+
"CANCELLED",
|
|
92
|
+
"TERMINATED"
|
|
93
|
+
];
|
|
94
|
+
/** Runs that can still be cancelled. */
|
|
95
|
+
const LIVE_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
96
|
+
"PENDING",
|
|
97
|
+
"RUNNING",
|
|
98
|
+
"WAITING"
|
|
99
|
+
]);
|
|
100
|
+
/** One page of latest versions — the same window the workflows list searches. */
|
|
101
|
+
const SEARCH_WINDOW = 100;
|
|
102
|
+
const MAX_NODE_LABELS = 20;
|
|
103
|
+
const workflowKey = z.string().trim().min(1).max(200).regex(/^[\w.:-]+$/, "must be a workflow key").describe("The workflow key, from fg_workflows_search.");
|
|
104
|
+
const version = z.string().trim().min(1).max(40).regex(/^[\w.-]+$/, "must be a version like 1.0.2").describe("The version, e.g. \"1.0.2\". Omit for the default version.");
|
|
105
|
+
const runId = z.string().trim().min(1).max(100).regex(/^[\w-]+$/, "must be a run id").describe("The run id, from fg_runs_list.");
|
|
106
|
+
const clip = (text, max) => {
|
|
107
|
+
if (!text) return void 0;
|
|
108
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
109
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
110
|
+
};
|
|
111
|
+
const durationMs = (seconds) => typeof seconds === "number" && Number.isFinite(seconds) ? Math.round(seconds * 1e3) : void 0;
|
|
112
|
+
const workflowPath = (key, ver, view) => `/workflows/${view === "builder" ? "builder" : "workflow"}/${encodeURIComponent(key)}/${encodeURIComponent(ver)}`;
|
|
113
|
+
const runPath = (id) => `/workflows/runs/${encodeURIComponent(id)}`;
|
|
114
|
+
function nodeLabel(node) {
|
|
115
|
+
const data = node.data && typeof node.data === "object" ? node.data : {};
|
|
116
|
+
for (const candidate of [
|
|
117
|
+
data.label,
|
|
118
|
+
data.name,
|
|
119
|
+
data.title,
|
|
120
|
+
node.type
|
|
121
|
+
]) if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
122
|
+
return "node";
|
|
123
|
+
}
|
|
124
|
+
/** The trigger nodes of a saved definition, the same test the analytics use. */
|
|
125
|
+
function triggerNodesOf(definition) {
|
|
126
|
+
return nodesOf(definition).filter((node) => {
|
|
127
|
+
const type = typeof node.type === "string" ? node.type.toLowerCase() : "";
|
|
128
|
+
const data = node.data && typeof node.data === "object" ? node.data : {};
|
|
129
|
+
return type.includes("trigger") || data.isTrigger === true || data.category === "trigger";
|
|
130
|
+
}).map((node) => {
|
|
131
|
+
const raw = node;
|
|
132
|
+
return {
|
|
133
|
+
id: typeof raw.id === "string" ? raw.id : "",
|
|
134
|
+
label: nodeLabel(node),
|
|
135
|
+
type: typeof raw.type === "string" ? raw.type : ""
|
|
136
|
+
};
|
|
137
|
+
}).filter((node) => node.id);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Which trigger a manual run starts from: the one asked for, else the manual
|
|
141
|
+
* trigger, else the only trigger. Ambiguity is the agent's to resolve.
|
|
142
|
+
*/
|
|
143
|
+
function pickTriggerNode(triggers, requested) {
|
|
144
|
+
if (requested) {
|
|
145
|
+
const match = triggers.find((node) => node.id === requested);
|
|
146
|
+
if (!match) throw new AgentToolError("INVALID_INPUT", `No trigger "${requested.slice(0, 60)}" in this workflow. Triggers: ${describeTriggers(triggers)}.`);
|
|
147
|
+
return match;
|
|
148
|
+
}
|
|
149
|
+
const manual = triggers.filter((node) => node.type.toLowerCase().includes("manual"));
|
|
150
|
+
if (manual.length === 1 && manual[0]) return manual[0];
|
|
151
|
+
if (triggers.length === 1 && triggers[0]) return triggers[0];
|
|
152
|
+
if (triggers.length === 0) throw new AgentToolError("CONFLICT", "This workflow has no trigger, so it cannot be started. Ask the person to add one in the builder.");
|
|
153
|
+
throw new AgentToolError("INVALID_INPUT", `This workflow has several triggers; pass triggerNodeId. Triggers: ${describeTriggers(triggers)}.`);
|
|
154
|
+
}
|
|
155
|
+
function describeTriggers(triggers) {
|
|
156
|
+
return triggers.slice(0, 6).map((node) => `${node.id} (${node.label})`).join(", ") || "none";
|
|
157
|
+
}
|
|
158
|
+
function createFluidGridsAgentTools(deps) {
|
|
159
|
+
const requireWorkspace = () => {
|
|
160
|
+
const workspaceId = deps.getWorkspaceId();
|
|
161
|
+
if (!workspaceId) throw new AgentToolError("NEEDS_USER", "No workspace is selected. Ask the person to choose one.");
|
|
162
|
+
return workspaceId;
|
|
163
|
+
};
|
|
164
|
+
/** A record answered for another workspace (a switch mid-request) is treated as missing. */
|
|
165
|
+
const sameWorkspace = (record, workspaceId) => !record.workspaceId || record.workspaceId === workspaceId;
|
|
166
|
+
const getWorkflow = async (key, ver, signal) => {
|
|
167
|
+
const workspaceId = requireWorkspace();
|
|
168
|
+
const workflow = (await deps.request({
|
|
169
|
+
operationName: "AgentGetWorkflow",
|
|
170
|
+
query: AGENT_GET_WORKFLOW,
|
|
171
|
+
variables: {
|
|
172
|
+
workflowKey: key,
|
|
173
|
+
...ver ? { version: ver } : {}
|
|
174
|
+
},
|
|
175
|
+
signal
|
|
176
|
+
})).workflowDefinition;
|
|
177
|
+
if (!workflow || workflow.deletedAt || !sameWorkspace(workflow, workspaceId)) throw new AgentToolError("NOT_FOUND", `No workflow "${key.slice(0, 60)}"${ver ? ` version ${ver}` : ""} in this workspace.`);
|
|
178
|
+
return workflow;
|
|
179
|
+
};
|
|
180
|
+
const search = defineAgentTool({
|
|
181
|
+
name: "fg_workflows_search",
|
|
182
|
+
title: "Find workflows",
|
|
183
|
+
description: "Finds workflows in the current FluidGrids workspace by name, description or key, optionally by status. Returns the latest version of each with its key, name, status and version. Use the key with the other fg_ tools.",
|
|
184
|
+
input: z.strictObject({
|
|
185
|
+
query: z.string().trim().max(100).optional().describe("Words to look for in the workflow name, description or key. Omit to list all."),
|
|
186
|
+
status: z.enum(WORKFLOW_STATUSES).optional().describe("Only workflows in this status."),
|
|
187
|
+
limit: z.number().int().min(1).max(20).default(10).describe("How many to return (1-20)."),
|
|
188
|
+
offset: z.number().int().min(0).max(SEARCH_WINDOW).default(0).describe("How many matches to skip, for paging.")
|
|
189
|
+
}),
|
|
190
|
+
annotations: {
|
|
191
|
+
readOnly: true,
|
|
192
|
+
untrustedContent: true
|
|
193
|
+
},
|
|
194
|
+
execute: async ({ query, status, limit, offset }, { signal }) => {
|
|
195
|
+
const workspaceId = requireWorkspace();
|
|
196
|
+
const filters = deps.listFilters?.();
|
|
197
|
+
const data = await deps.request({
|
|
198
|
+
operationName: "AgentFindWorkflows",
|
|
199
|
+
query: AGENT_FIND_WORKFLOWS,
|
|
200
|
+
variables: { input: {
|
|
201
|
+
latestOnly: true,
|
|
202
|
+
limit: SEARCH_WINDOW,
|
|
203
|
+
offset: 0,
|
|
204
|
+
...status ? { status } : {},
|
|
205
|
+
...filters && Object.keys(filters).length > 0 ? { customFilters: filters } : {}
|
|
206
|
+
} },
|
|
207
|
+
signal
|
|
208
|
+
});
|
|
209
|
+
const needle = query?.toLowerCase();
|
|
210
|
+
const matches = (data.workflowDefinitions?.items ?? []).filter((item) => !item.deletedAt && sameWorkspace(item, workspaceId) && (!needle || [
|
|
211
|
+
item.name,
|
|
212
|
+
item.description,
|
|
213
|
+
item.workflowKey
|
|
214
|
+
].some((field) => field?.toLowerCase().includes(needle))));
|
|
215
|
+
const page = matches.slice(offset, offset + limit);
|
|
216
|
+
return {
|
|
217
|
+
total: matches.length,
|
|
218
|
+
workflows: page.map((item) => ({
|
|
219
|
+
workflowKey: item.workflowKey,
|
|
220
|
+
name: clip(item.name, 80),
|
|
221
|
+
status: item.status,
|
|
222
|
+
version: item.version,
|
|
223
|
+
updatedAt: item.updatedAt
|
|
224
|
+
})),
|
|
225
|
+
...offset + page.length < matches.length ? { nextOffset: offset + page.length } : {}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
const get = defineAgentTool({
|
|
230
|
+
name: "fg_workflow_get",
|
|
231
|
+
title: "Read a workflow",
|
|
232
|
+
description: "Reads one workflow version: name, status, version, number of versions, its triggers and the labels of its nodes. Omit version for the default version.",
|
|
233
|
+
input: z.strictObject({
|
|
234
|
+
workflowKey,
|
|
235
|
+
version: version.optional()
|
|
236
|
+
}),
|
|
237
|
+
annotations: {
|
|
238
|
+
readOnly: true,
|
|
239
|
+
untrustedContent: true
|
|
240
|
+
},
|
|
241
|
+
execute: async ({ workflowKey: key, version: ver }, { signal }) => {
|
|
242
|
+
const workflow = await getWorkflow(key, ver, signal);
|
|
243
|
+
const nodes = nodesOf(workflow.definitionJson);
|
|
244
|
+
return {
|
|
245
|
+
workflowKey: workflow.workflowKey,
|
|
246
|
+
name: clip(workflow.name, 80),
|
|
247
|
+
status: workflow.status,
|
|
248
|
+
version: workflow.version,
|
|
249
|
+
totalVersions: workflow.totalVersions ?? void 0,
|
|
250
|
+
updatedAt: workflow.updatedAt,
|
|
251
|
+
description: clip(workflow.description, 200),
|
|
252
|
+
triggers: triggerNodesOf(workflow.definitionJson).map((node) => ({
|
|
253
|
+
nodeId: node.id,
|
|
254
|
+
label: clip(node.label, 60)
|
|
255
|
+
})),
|
|
256
|
+
nodeCount: nodes.length,
|
|
257
|
+
nodes: nodes.slice(0, MAX_NODE_LABELS).map((node) => clip(nodeLabel(node), 60))
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
const listRuns = defineAgentTool({
|
|
262
|
+
name: "fg_runs_list",
|
|
263
|
+
title: "List runs",
|
|
264
|
+
description: "Lists recent workflow runs in the current workspace, newest first, optionally for one workflow, by status or since a date. Returns run id, workflow name, status, start time, duration and a short error.",
|
|
265
|
+
input: z.strictObject({
|
|
266
|
+
workflowKey: workflowKey.optional(),
|
|
267
|
+
status: z.enum(RUN_STATUSES).optional().describe("Only runs in this status."),
|
|
268
|
+
since: z.iso.datetime({ offset: true }).optional().describe("Only runs started at or after this ISO date-time, e.g. 2026-10-27T00:00:00Z."),
|
|
269
|
+
search: z.string().trim().max(100).optional().describe("Matches a run id prefix, or a workflow key or name."),
|
|
270
|
+
limit: z.number().int().min(1).max(20).default(10).describe("How many to return (1-20)."),
|
|
271
|
+
offset: z.number().int().min(0).max(1e4).default(0).describe("How many to skip, for paging.")
|
|
272
|
+
}),
|
|
273
|
+
annotations: {
|
|
274
|
+
readOnly: true,
|
|
275
|
+
untrustedContent: true
|
|
276
|
+
},
|
|
277
|
+
execute: async ({ workflowKey: key, status, since, search: text, limit, offset }, { signal }) => {
|
|
278
|
+
requireWorkspace();
|
|
279
|
+
const data = await deps.request({
|
|
280
|
+
operationName: "AgentListRuns",
|
|
281
|
+
query: AGENT_LIST_RUNS,
|
|
282
|
+
variables: { input: {
|
|
283
|
+
limit,
|
|
284
|
+
offset,
|
|
285
|
+
...key ? { workflowKey: key } : {},
|
|
286
|
+
...status ? { status } : {},
|
|
287
|
+
...since ? { startDate: since } : {},
|
|
288
|
+
...text ? { search: text } : {}
|
|
289
|
+
} },
|
|
290
|
+
signal
|
|
291
|
+
});
|
|
292
|
+
const items = data.workflowRuns?.items ?? [];
|
|
293
|
+
return {
|
|
294
|
+
total: data.workflowRuns?.total ?? items.length,
|
|
295
|
+
runs: items.map((run) => ({
|
|
296
|
+
runId: run.id,
|
|
297
|
+
workflowKey: run.workflowKey,
|
|
298
|
+
workflowName: clip(run.workflow?.name, 60),
|
|
299
|
+
version: run.version,
|
|
300
|
+
status: run.status,
|
|
301
|
+
...run.mode === "TEST" ? { mode: "TEST" } : {},
|
|
302
|
+
startedAt: run.startedAt ?? void 0,
|
|
303
|
+
durationMs: durationMs(run.duration),
|
|
304
|
+
error: clip(run.errorMessage, 120)
|
|
305
|
+
})),
|
|
306
|
+
...data.workflowRuns?.hasMore ? { nextOffset: offset + items.length } : {}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const getRun = defineAgentTool({
|
|
311
|
+
name: "fg_run_get",
|
|
312
|
+
title: "Read a run",
|
|
313
|
+
description: "Reads one workflow run: status, timing, node counts, the nodes that failed and the error. Use it to explain why a run failed.",
|
|
314
|
+
input: z.strictObject({ runId }),
|
|
315
|
+
annotations: {
|
|
316
|
+
readOnly: true,
|
|
317
|
+
untrustedContent: true
|
|
318
|
+
},
|
|
319
|
+
execute: async ({ runId: id }, { signal }) => {
|
|
320
|
+
requireWorkspace();
|
|
321
|
+
const run = (await deps.request({
|
|
322
|
+
operationName: "AgentGetRun",
|
|
323
|
+
query: AGENT_GET_RUN,
|
|
324
|
+
variables: { id },
|
|
325
|
+
signal
|
|
326
|
+
})).workflowRun;
|
|
327
|
+
if (!run) throw new AgentToolError("NOT_FOUND", "No run with that id in this workspace.");
|
|
328
|
+
const failed = (run.nodeStates ?? []).filter((node) => node.status === "FAILED");
|
|
329
|
+
return {
|
|
330
|
+
runId: run.id,
|
|
331
|
+
workflowKey: run.workflowKey,
|
|
332
|
+
workflowName: clip(run.workflow?.name, 60),
|
|
333
|
+
version: run.version,
|
|
334
|
+
status: run.status,
|
|
335
|
+
mode: run.mode,
|
|
336
|
+
startedAt: run.startedAt ?? void 0,
|
|
337
|
+
completedAt: run.completedAt ?? void 0,
|
|
338
|
+
durationMs: durationMs(run.duration),
|
|
339
|
+
nodes: {
|
|
340
|
+
total: run.totalNodes ?? void 0,
|
|
341
|
+
completed: run.completedNodes ?? void 0,
|
|
342
|
+
failed: run.failedNodes ?? void 0
|
|
343
|
+
},
|
|
344
|
+
failedNodes: failed.slice(0, 3).map((node) => ({
|
|
345
|
+
nodeId: node.nodeId,
|
|
346
|
+
type: node.nodeType,
|
|
347
|
+
error: clip(node.error, 200)
|
|
348
|
+
})),
|
|
349
|
+
error: clip(run.errorMessage, 300),
|
|
350
|
+
errorType: run.errorType ?? void 0
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
const open = defineAgentTool({
|
|
355
|
+
name: "fg_workflow_open",
|
|
356
|
+
title: "Open a workflow or run",
|
|
357
|
+
description: "Opens a workflow (its details page or the builder) or a run in the person's tab. Pass workflowKey (and optionally version and view) or runId. Refused while the page has unsaved changes.",
|
|
358
|
+
input: z.strictObject({
|
|
359
|
+
workflowKey: workflowKey.optional(),
|
|
360
|
+
version: version.optional(),
|
|
361
|
+
view: z.enum(["details", "builder"]).default("details").describe("For a workflow: its details page or the builder."),
|
|
362
|
+
runId: runId.optional()
|
|
363
|
+
}),
|
|
364
|
+
annotations: { navigation: true },
|
|
365
|
+
execute: async ({ workflowKey: key, version: ver, view, runId: id }, { signal }) => {
|
|
366
|
+
if (!key === !id) throw new AgentToolError("INVALID_INPUT", "Pass either workflowKey or runId.");
|
|
367
|
+
assertNoUnsavedChanges();
|
|
368
|
+
let path;
|
|
369
|
+
if (id) path = runPath(id);
|
|
370
|
+
else {
|
|
371
|
+
const workflow = await getWorkflow(key ?? "", ver, signal);
|
|
372
|
+
path = workflowPath(workflow.workflowKey ?? key ?? "", workflow.version ?? ver ?? "", view);
|
|
373
|
+
}
|
|
374
|
+
deps.navigate(path);
|
|
375
|
+
return { navigatedTo: path };
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const setActive = (activate) => defineAgentTool({
|
|
379
|
+
name: activate ? "fg_workflow_activate" : "fg_workflow_deactivate",
|
|
380
|
+
title: activate ? "Activate a workflow version" : "Deactivate a workflow version",
|
|
381
|
+
description: activate ? "Makes one version of a workflow the active one, so its triggers (schedules, webhooks, app events) start running it. The previously active version is archived. Asks the person to confirm." : "Deactivates the active version of a workflow, so its triggers stop starting runs. Asks the person to confirm.",
|
|
382
|
+
input: z.strictObject({
|
|
383
|
+
workflowKey,
|
|
384
|
+
version: version.describe("The version to change.")
|
|
385
|
+
}),
|
|
386
|
+
annotations: { consequential: true },
|
|
387
|
+
confirm: ({ workflowKey: key, version: ver }) => ({
|
|
388
|
+
title: activate ? "Activate this workflow version?" : "Deactivate this workflow?",
|
|
389
|
+
body: activate ? `Workflow "${key}" version ${ver} becomes the active version and its triggers start running it. The version active now is archived.` : `Workflow "${key}" version ${ver} stops running: its schedules, webhooks and app-event triggers stop starting runs.`,
|
|
390
|
+
confirmLabel: activate ? "Activate" : "Deactivate"
|
|
391
|
+
}),
|
|
392
|
+
execute: async ({ workflowKey: key, version: ver }, { signal }) => {
|
|
393
|
+
const workflow = await getWorkflow(key, ver, signal);
|
|
394
|
+
if (!workflow.id) throw new AgentToolError("NOT_FOUND", "That workflow version has no id.");
|
|
395
|
+
if (activate && workflow.status === "ACTIVE") return {
|
|
396
|
+
workflowKey: key,
|
|
397
|
+
version: ver,
|
|
398
|
+
status: "ACTIVE",
|
|
399
|
+
changed: false
|
|
400
|
+
};
|
|
401
|
+
if (!activate && workflow.status !== "ACTIVE") throw new AgentToolError("CONFLICT", `That version is ${workflow.status}, not ACTIVE.`);
|
|
402
|
+
const result = (await deps.request({
|
|
403
|
+
operationName: activate ? "AgentActivateWorkflow" : "AgentDeactivateWorkflow",
|
|
404
|
+
query: activate ? AGENT_ACTIVATE_WORKFLOW : AGENT_DEACTIVATE_WORKFLOW,
|
|
405
|
+
variables: { id: workflow.id },
|
|
406
|
+
signal
|
|
407
|
+
}))[activate ? "activateWorkflowDefinition" : "deactivateWorkflowDefinition"];
|
|
408
|
+
if (!result?.status) throw new AgentToolError("FAILED", "The backend did not confirm the change.");
|
|
409
|
+
return {
|
|
410
|
+
workflowKey: key,
|
|
411
|
+
version: result.version ?? ver,
|
|
412
|
+
status: result.status,
|
|
413
|
+
changed: true
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
const run = defineAgentTool({
|
|
418
|
+
name: "fg_workflow_run",
|
|
419
|
+
title: "Run a workflow",
|
|
420
|
+
description: "Starts a production run of a workflow's active version from its manual trigger (or the given trigger), with optional input data. Uses the workspace's run quota. Asks the person to confirm. Returns the run id; follow it with fg_run_get.",
|
|
421
|
+
input: z.strictObject({
|
|
422
|
+
workflowKey,
|
|
423
|
+
triggerNodeId: z.string().trim().min(1).max(100).optional().describe("The trigger to start from, from fg_workflow_get. Omit for the manual trigger."),
|
|
424
|
+
input: z.string().max(4e3).optional().describe("Input data for the trigger as a JSON object string, e.g. {\"email\":\"a@b.co\"}.")
|
|
425
|
+
}),
|
|
426
|
+
annotations: { consequential: true },
|
|
427
|
+
confirm: ({ workflowKey: key, input }) => ({
|
|
428
|
+
title: "Run this workflow?",
|
|
429
|
+
body: `Start a production run of workflow "${key}"${input ? " with the input the agent prepared" : ""}. It counts toward this workspace's runs and its steps take effect.`,
|
|
430
|
+
confirmLabel: "Run workflow"
|
|
431
|
+
}),
|
|
432
|
+
execute: async ({ workflowKey: key, triggerNodeId, input }, { signal }) => {
|
|
433
|
+
let triggerData;
|
|
434
|
+
if (input) {
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = JSON.parse(input);
|
|
438
|
+
} catch {
|
|
439
|
+
throw new AgentToolError("INVALID_INPUT", "input must be a JSON object string.");
|
|
440
|
+
}
|
|
441
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AgentToolError("INVALID_INPUT", "input must be a JSON object.");
|
|
442
|
+
triggerData = parsed;
|
|
443
|
+
}
|
|
444
|
+
const workflow = await getWorkflow(key, void 0, signal);
|
|
445
|
+
if (workflow.status !== "ACTIVE" || !workflow.version) throw new AgentToolError("CONFLICT", "Only an active workflow version can be run here. Activate a version first, or ask the person to test it in the builder.");
|
|
446
|
+
const trigger = pickTriggerNode(triggerNodesOf(workflow.definitionJson), triggerNodeId);
|
|
447
|
+
const started = (await deps.request({
|
|
448
|
+
operationName: "AgentTriggerWorkflow",
|
|
449
|
+
query: AGENT_TRIGGER_WORKFLOW,
|
|
450
|
+
variables: { input: {
|
|
451
|
+
workflowKey: key,
|
|
452
|
+
version: workflow.version,
|
|
453
|
+
triggerNodeId: trigger.id,
|
|
454
|
+
mode: "PRODUCTION",
|
|
455
|
+
idempotencyKey: `agent-${crypto.randomUUID()}`,
|
|
456
|
+
...triggerData ? { triggerData } : {}
|
|
457
|
+
} },
|
|
458
|
+
signal
|
|
459
|
+
})).triggerWorkflow;
|
|
460
|
+
if (!started?.runId) throw new AgentToolError("FAILED", "The workflow did not start.");
|
|
461
|
+
return {
|
|
462
|
+
runId: started.runId,
|
|
463
|
+
status: started.status,
|
|
464
|
+
workflowKey: key,
|
|
465
|
+
version: started.version ?? workflow.version,
|
|
466
|
+
trigger: clip(trigger.label, 60)
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
const cancel = defineAgentTool({
|
|
471
|
+
name: "fg_run_cancel",
|
|
472
|
+
title: "Cancel a run",
|
|
473
|
+
description: "Cancels a workflow run that is pending, running or waiting. Steps already done are not undone. Asks the person to confirm.",
|
|
474
|
+
input: z.strictObject({ runId }),
|
|
475
|
+
annotations: { consequential: true },
|
|
476
|
+
confirm: ({ runId: id }) => ({
|
|
477
|
+
title: "Cancel this run?",
|
|
478
|
+
body: `Run ${id} stops. Steps it already completed are not undone.`,
|
|
479
|
+
confirmLabel: "Cancel run"
|
|
480
|
+
}),
|
|
481
|
+
execute: async ({ runId: id }, { signal }) => {
|
|
482
|
+
requireWorkspace();
|
|
483
|
+
const status = (await deps.request({
|
|
484
|
+
operationName: "AgentGetRun",
|
|
485
|
+
query: AGENT_GET_RUN,
|
|
486
|
+
variables: { id },
|
|
487
|
+
signal
|
|
488
|
+
})).workflowRun?.status;
|
|
489
|
+
if (!status) throw new AgentToolError("NOT_FOUND", "No run with that id in this workspace.");
|
|
490
|
+
if (!LIVE_RUN_STATUSES.has(status)) throw new AgentToolError("CONFLICT", `That run already finished (${status}).`);
|
|
491
|
+
const data = await deps.request({
|
|
492
|
+
operationName: "AgentCancelRun",
|
|
493
|
+
query: AGENT_CANCEL_RUN,
|
|
494
|
+
variables: { id },
|
|
495
|
+
signal
|
|
496
|
+
});
|
|
497
|
+
if (!data.cancelWorkflowRun?.status) throw new AgentToolError("FAILED", "The backend did not confirm the cancellation.");
|
|
498
|
+
return {
|
|
499
|
+
runId: id,
|
|
500
|
+
status: data.cancelWorkflowRun.status
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
return [
|
|
505
|
+
search,
|
|
506
|
+
get,
|
|
507
|
+
listRuns,
|
|
508
|
+
getRun,
|
|
509
|
+
open,
|
|
510
|
+
setActive(true),
|
|
511
|
+
setActive(false),
|
|
512
|
+
run,
|
|
513
|
+
cancel
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/agentMarks/findTargets.ts
|
|
518
|
+
/**
|
|
519
|
+
* ★ BOFF-11584: find what the assistant wants to point at, the way assistive
|
|
520
|
+
* technology names it — the same view `ui_snapshot` gives the agent.
|
|
521
|
+
*
|
|
522
|
+
* Besides the controls `ui_snapshot` lists, headings and labelled regions can
|
|
523
|
+
* be pointed at ("the Run history section is here"). Regions an agent may not
|
|
524
|
+
* see (`data-agent="deny"`, the assistant's own panel, confirmation dialogs,
|
|
525
|
+
* and the marks overlay itself) are never candidates.
|
|
526
|
+
*/
|
|
527
|
+
const LANDMARK_SELECTOR = [
|
|
528
|
+
"h1",
|
|
529
|
+
"h2",
|
|
530
|
+
"h3",
|
|
531
|
+
"h4",
|
|
532
|
+
"[role=\"heading\"]",
|
|
533
|
+
"[role=\"tabpanel\"][aria-label]",
|
|
534
|
+
"[role=\"region\"][aria-label]",
|
|
535
|
+
"[role=\"region\"][aria-labelledby]",
|
|
536
|
+
"section[aria-label]",
|
|
537
|
+
"section[aria-labelledby]",
|
|
538
|
+
"nav[aria-label]",
|
|
539
|
+
"aside[aria-label]",
|
|
540
|
+
"form[aria-label]"
|
|
541
|
+
].join(", ");
|
|
542
|
+
const POINTABLE_SELECTOR = `${INTERACTIVE_SELECTOR}, ${LANDMARK_SELECTOR}`;
|
|
543
|
+
const LANDMARK_ROLE = {
|
|
544
|
+
h1: "heading",
|
|
545
|
+
h2: "heading",
|
|
546
|
+
h3: "heading",
|
|
547
|
+
h4: "heading",
|
|
548
|
+
section: "region",
|
|
549
|
+
nav: "navigation",
|
|
550
|
+
aside: "complementary",
|
|
551
|
+
form: "form"
|
|
552
|
+
};
|
|
553
|
+
/** The role an agent knows the element by. */
|
|
554
|
+
function pointableRole(element) {
|
|
555
|
+
const role = roleOf(element);
|
|
556
|
+
if (role !== "generic") return role;
|
|
557
|
+
return LANDMARK_ROLE[element.tagName.toLowerCase()] ?? role;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Rendered and reachable: connected, not hidden, not inert, not behind a modal
|
|
561
|
+
* (a modal dialog marks the rest of the page `aria-hidden`), not
|
|
562
|
+
* `display: none` / `visibility: hidden`.
|
|
563
|
+
*/
|
|
564
|
+
function isPointable(element) {
|
|
565
|
+
if (!element.isConnected) return false;
|
|
566
|
+
if (element.closest("[hidden], [inert], [aria-hidden=\"true\"]")) return false;
|
|
567
|
+
if (isDenied(element)) return false;
|
|
568
|
+
const withCheck = element;
|
|
569
|
+
if (typeof withCheck.checkVisibility === "function") return withCheck.checkVisibility({ checkVisibilityCSS: true });
|
|
570
|
+
const view = element.ownerDocument.defaultView;
|
|
571
|
+
if (!view) return true;
|
|
572
|
+
for (let node = element; node; node = node.parentElement) {
|
|
573
|
+
const style = view.getComputedStyle(node);
|
|
574
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
function normalise(text) {
|
|
579
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
580
|
+
}
|
|
581
|
+
function rankOf(name, needle) {
|
|
582
|
+
const value = normalise(name);
|
|
583
|
+
if (!value) return null;
|
|
584
|
+
if (value === needle) return 0;
|
|
585
|
+
if (value.startsWith(needle)) return 1;
|
|
586
|
+
if (value.includes(needle)) return 2;
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Every visible element whose accessible name holds `name` (and, when given,
|
|
591
|
+
* whose role is `role`), best match first and in page order within a rank.
|
|
592
|
+
* An element nested in another match with the same name (a heading's inner
|
|
593
|
+
* link, say) is dropped in favour of the outer one only when both rank alike.
|
|
594
|
+
*/
|
|
595
|
+
function findPointTargets(root, query) {
|
|
596
|
+
const needle = normalise(query.name);
|
|
597
|
+
if (!needle) return [];
|
|
598
|
+
const wantedRole = query.role?.toLowerCase();
|
|
599
|
+
const matches = [];
|
|
600
|
+
for (const element of Array.from(root.querySelectorAll(POINTABLE_SELECTOR))) {
|
|
601
|
+
const role = pointableRole(element);
|
|
602
|
+
if (role === "generic") continue;
|
|
603
|
+
if (wantedRole && role !== wantedRole) continue;
|
|
604
|
+
const name = accessibleName(element);
|
|
605
|
+
const rank = rankOf(name, needle);
|
|
606
|
+
if (rank === null) continue;
|
|
607
|
+
if (!isPointable(element)) continue;
|
|
608
|
+
matches.push({
|
|
609
|
+
element,
|
|
610
|
+
role,
|
|
611
|
+
name,
|
|
612
|
+
rank
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return matches.filter((match) => !matches.some((other) => other !== match && other.rank === match.rank && normalise(other.name) === normalise(match.name) && other.element.contains(match.element))).sort((a, b) => a.rank - b.rank);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Where `element` sits among the visible elements with the same role and
|
|
619
|
+
* exact name (1-based), so it can be found again after a re-render replaces
|
|
620
|
+
* the node.
|
|
621
|
+
*/
|
|
622
|
+
function positionAmongTwins(root, match) {
|
|
623
|
+
const index = findPointTargets(root, {
|
|
624
|
+
name: match.name,
|
|
625
|
+
role: match.role
|
|
626
|
+
}).filter((other) => normalise(other.name) === normalise(match.name)).findIndex((other) => other.element === match.element);
|
|
627
|
+
return index < 0 ? 1 : index + 1;
|
|
628
|
+
}
|
|
629
|
+
/** Find a callout's element again: same role, same exact name, same position. */
|
|
630
|
+
function refindTarget(root, target) {
|
|
631
|
+
return findPointTargets(root, {
|
|
632
|
+
name: target.name,
|
|
633
|
+
role: target.role
|
|
634
|
+
}).filter((other) => normalise(other.name) === normalise(target.name))[target.nth - 1]?.element ?? null;
|
|
635
|
+
}
|
|
636
|
+
/** The candidates the person could mean, as one line each for the agent. */
|
|
637
|
+
function describeMatches(matches, limit = 6) {
|
|
638
|
+
return matches.slice(0, limit).map((match, index) => `${index + 1}. ${match.role} ${JSON.stringify(match.name)}`).join("; ");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/agentMarks/AgentCalloutOverlay.tsx
|
|
642
|
+
const RING_PAD = 4;
|
|
643
|
+
const BUBBLE_GAP = 10;
|
|
644
|
+
const BUBBLE_MAX_WIDTH = 280;
|
|
645
|
+
/** Room the bubble needs below the element before it flips above. */
|
|
646
|
+
const BUBBLE_ROOM = 120;
|
|
647
|
+
const EDGE = 8;
|
|
648
|
+
const REFIND_EVERY_MS = 400;
|
|
649
|
+
function boxOf(element) {
|
|
650
|
+
if (!isPointable(element)) return null;
|
|
651
|
+
const rect = element.getBoundingClientRect();
|
|
652
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
653
|
+
return {
|
|
654
|
+
top: Math.round(rect.top),
|
|
655
|
+
left: Math.round(rect.left),
|
|
656
|
+
width: Math.round(rect.width),
|
|
657
|
+
height: Math.round(rect.height)
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** Where each callout's element is now (null: not on screen). */
|
|
661
|
+
function useCalloutBoxes(callouts) {
|
|
662
|
+
const [boxes, setBoxes] = useState({});
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (callouts.length === 0) {
|
|
665
|
+
setBoxes({});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const lastRefind = /* @__PURE__ */ new Map();
|
|
669
|
+
let frame = 0;
|
|
670
|
+
let previous = "";
|
|
671
|
+
const tick = () => {
|
|
672
|
+
const here = window.location.pathname;
|
|
673
|
+
if (callouts.some((callout) => callout.route !== here)) {
|
|
674
|
+
useAgentMarksStore.getState().actions.dropCalloutsOffRoute(here);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const next = {};
|
|
678
|
+
for (const callout of callouts) {
|
|
679
|
+
let element = callout.element?.deref() ?? null;
|
|
680
|
+
if (!element?.isConnected) {
|
|
681
|
+
element = null;
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
if (now - (lastRefind.get(callout.id) ?? 0) >= REFIND_EVERY_MS) {
|
|
684
|
+
lastRefind.set(callout.id, now);
|
|
685
|
+
const found = refindTarget(document.body, callout.target);
|
|
686
|
+
if (found) {
|
|
687
|
+
useAgentMarksStore.getState().actions.rebindCallout(callout.id, found);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
next[callout.id] = element ? boxOf(element) : null;
|
|
693
|
+
}
|
|
694
|
+
const key = JSON.stringify(next);
|
|
695
|
+
if (key !== previous) {
|
|
696
|
+
previous = key;
|
|
697
|
+
setBoxes(next);
|
|
698
|
+
}
|
|
699
|
+
frame = requestAnimationFrame(tick);
|
|
700
|
+
};
|
|
701
|
+
tick();
|
|
702
|
+
return () => cancelAnimationFrame(frame);
|
|
703
|
+
}, [callouts]);
|
|
704
|
+
return boxes;
|
|
705
|
+
}
|
|
706
|
+
/** Clicking the element pointed at (the person found it) or Escape clears. */
|
|
707
|
+
function useDismissals(callouts) {
|
|
708
|
+
useEffect(() => {
|
|
709
|
+
if (callouts.length === 0) return;
|
|
710
|
+
const { removeCallout, clearCallouts } = useAgentMarksStore.getState().actions;
|
|
711
|
+
const onPointerDown = (event) => {
|
|
712
|
+
const target = event.target;
|
|
713
|
+
if (!(target instanceof Node)) return;
|
|
714
|
+
for (const callout of callouts) if (callout.element?.deref()?.contains(target)) removeCallout(callout.id);
|
|
715
|
+
};
|
|
716
|
+
const onKeyDown = (event) => {
|
|
717
|
+
if (event.key === "Escape") clearCallouts();
|
|
718
|
+
};
|
|
719
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
720
|
+
document.addEventListener("keydown", onKeyDown);
|
|
721
|
+
return () => {
|
|
722
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
723
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
724
|
+
};
|
|
725
|
+
}, [callouts]);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Below the element when there is room, otherwise above; kept inside the
|
|
729
|
+
* viewport and clear of the docked assistant panel.
|
|
730
|
+
*/
|
|
731
|
+
function placeBubble(box, viewport, rightInset) {
|
|
732
|
+
const usableRight = Math.max(viewport.width - rightInset - EDGE, 16);
|
|
733
|
+
const maxWidth = Math.min(BUBBLE_MAX_WIDTH, usableRight - EDGE);
|
|
734
|
+
const left = Math.min(Math.max(box.left, EDGE), Math.max(usableRight - maxWidth, EDGE));
|
|
735
|
+
const below = box.top + box.height + RING_PAD + BUBBLE_GAP;
|
|
736
|
+
if (below + BUBBLE_ROOM <= viewport.height || box.top < BUBBLE_ROOM) return {
|
|
737
|
+
top: below,
|
|
738
|
+
left,
|
|
739
|
+
maxWidth
|
|
740
|
+
};
|
|
741
|
+
return {
|
|
742
|
+
bottom: viewport.height - box.top + RING_PAD + BUBBLE_GAP,
|
|
743
|
+
left,
|
|
744
|
+
maxWidth
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const AgentCalloutOverlay = ({ rightInset = 0 }) => {
|
|
748
|
+
const tr = useTr();
|
|
749
|
+
const callouts = useAgentMarksStore((state) => state.callouts);
|
|
750
|
+
const boxes = useCalloutBoxes(callouts);
|
|
751
|
+
useDismissals(callouts);
|
|
752
|
+
if (callouts.length === 0 || typeof document === "undefined") return null;
|
|
753
|
+
const viewport = {
|
|
754
|
+
width: window.innerWidth,
|
|
755
|
+
height: window.innerHeight
|
|
756
|
+
};
|
|
757
|
+
return createPortal(/* @__PURE__ */ jsx("div", {
|
|
758
|
+
"data-agent": "deny",
|
|
759
|
+
"data-testid": "fg-agent-callouts",
|
|
760
|
+
className: "pointer-events-none fixed inset-0 z-[1000]",
|
|
761
|
+
children: callouts.map((callout) => {
|
|
762
|
+
const box = boxes[callout.id];
|
|
763
|
+
if (!box) return null;
|
|
764
|
+
const placement = placeBubble(box, viewport, rightInset);
|
|
765
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
766
|
+
"data-testid": "fg-agent-callout",
|
|
767
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
768
|
+
"aria-hidden": "true",
|
|
769
|
+
"data-testid": "fg-agent-callout-ring",
|
|
770
|
+
className: "absolute rounded-md ring-2 ring-primary ring-offset-2 ring-offset-background motion-safe:animate-pulse",
|
|
771
|
+
style: {
|
|
772
|
+
top: box.top - RING_PAD,
|
|
773
|
+
left: box.left - RING_PAD,
|
|
774
|
+
width: box.width + 8,
|
|
775
|
+
height: box.height + 8
|
|
776
|
+
}
|
|
777
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
778
|
+
role: "status",
|
|
779
|
+
className: "pointer-events-auto absolute rounded-lg border border-border bg-popover p-3 text-sm text-foreground shadow-lg",
|
|
780
|
+
style: {
|
|
781
|
+
top: placement.top,
|
|
782
|
+
bottom: placement.bottom,
|
|
783
|
+
left: placement.left,
|
|
784
|
+
maxWidth: placement.maxWidth
|
|
785
|
+
},
|
|
786
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
787
|
+
className: "flex items-start gap-2",
|
|
788
|
+
children: [
|
|
789
|
+
/* @__PURE__ */ jsx(Sparkles, {
|
|
790
|
+
"aria-hidden": "true",
|
|
791
|
+
className: "mt-0.5 size-4 shrink-0 text-primary"
|
|
792
|
+
}),
|
|
793
|
+
/* @__PURE__ */ jsx("p", {
|
|
794
|
+
"data-testid": "fg-agent-callout-label",
|
|
795
|
+
className: "min-w-0 whitespace-pre-line break-words",
|
|
796
|
+
children: callout.label
|
|
797
|
+
}),
|
|
798
|
+
/* @__PURE__ */ jsx("button", {
|
|
799
|
+
type: "button",
|
|
800
|
+
"aria-label": tr("fluidgrids.agentMarks.dismissCallout", "Dismiss"),
|
|
801
|
+
onClick: () => useAgentMarksStore.getState().actions.removeCallout(callout.id),
|
|
802
|
+
className: "-m-1 shrink-0 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
803
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
804
|
+
"aria-hidden": "true",
|
|
805
|
+
className: "size-4"
|
|
806
|
+
})
|
|
807
|
+
})
|
|
808
|
+
]
|
|
809
|
+
})
|
|
810
|
+
})]
|
|
811
|
+
}, callout.id);
|
|
812
|
+
})
|
|
813
|
+
}), document.body);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// src/agentMarks/uiMarkTools.ts
|
|
817
|
+
/**
|
|
818
|
+
* ★ BOFF-11584: show the person where something is.
|
|
819
|
+
*
|
|
820
|
+
* - `fg_ui_point_at` draws a labelled callout on one control or heading.
|
|
821
|
+
* - `fg_ui_clear_marks` removes the assistant's callouts and canvas notes.
|
|
822
|
+
*
|
|
823
|
+
* Both are read-only: they change nothing in the workspace, only what the
|
|
824
|
+
* overlay draws, so Ask (promised read-only) may offer them too
|
|
825
|
+
* (fe-libs `isReadOnlyModeTool`). The overlay is `AgentCalloutOverlay`.
|
|
826
|
+
*/
|
|
827
|
+
/** How long to wait for the element after a navigation. */
|
|
828
|
+
const POINT_WAIT_MS = 3e3;
|
|
829
|
+
const POINT_POLL_MS = 150;
|
|
830
|
+
const ROLE_PATTERN = /^[a-z]{2,24}$/;
|
|
831
|
+
function sleep(ms, signal) {
|
|
832
|
+
return new Promise((resolve, reject) => {
|
|
833
|
+
if (signal.aborted) {
|
|
834
|
+
reject(new AgentToolError("CANCELLED", "The call was cancelled."));
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const timer = setTimeout(() => {
|
|
838
|
+
signal.removeEventListener("abort", onAbort);
|
|
839
|
+
resolve();
|
|
840
|
+
}, ms);
|
|
841
|
+
const onAbort = () => {
|
|
842
|
+
clearTimeout(timer);
|
|
843
|
+
reject(new AgentToolError("CANCELLED", "The call was cancelled."));
|
|
844
|
+
};
|
|
845
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
/** Matches as soon as there are any, or none after the wait. */
|
|
849
|
+
async function waitForMatches(root, query, waitMs, signal) {
|
|
850
|
+
const deadline = Date.now() + waitMs;
|
|
851
|
+
for (;;) {
|
|
852
|
+
const scope = root();
|
|
853
|
+
const matches = scope ? findPointTargets(scope, query) : [];
|
|
854
|
+
if (matches.length > 0 || Date.now() >= deadline) return matches;
|
|
855
|
+
await sleep(POINT_POLL_MS, signal);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
function pick(matches, nth) {
|
|
859
|
+
if (nth !== void 0) {
|
|
860
|
+
const chosen = matches[nth - 1];
|
|
861
|
+
if (!chosen) throw new AgentToolError("INVALID_INPUT", `Only ${matches.length} match: ${describeMatches(matches)}.`);
|
|
862
|
+
return chosen;
|
|
863
|
+
}
|
|
864
|
+
const best = matches.filter((match) => match.rank === matches[0]?.rank);
|
|
865
|
+
if (best.length === 1 && best[0]) return best[0];
|
|
866
|
+
throw new AgentToolError("INVALID_INPUT", `${matches.length} elements match: ${describeMatches(matches)}. Call again with nth, or a fuller name or a role.`);
|
|
867
|
+
}
|
|
868
|
+
function createUiMarkTools(options = {}) {
|
|
869
|
+
const root = options.root ?? (() => typeof document === "undefined" ? null : document.body);
|
|
870
|
+
const waitMs = options.waitMs ?? 3e3;
|
|
871
|
+
return [defineAgentTool({
|
|
872
|
+
name: "fg_ui_point_at",
|
|
873
|
+
title: "Point at something on screen",
|
|
874
|
+
description: "Shows the person where a control or heading is: scrolls it into view and draws a callout with your label beside it. Use it when they ask where something is or how to do something on this page. Open the right page first (app_navigate). The callout stays until they click the control, close it, or leave the page.",
|
|
875
|
+
input: z.strictObject({
|
|
876
|
+
name: z.string().trim().min(1).max(80).describe("The visible name of the control or heading, as on screen or in ui_snapshot, e.g. Cancel run."),
|
|
877
|
+
role: z.string().trim().toLowerCase().regex(ROLE_PATTERN, "a role like button, link, tab or heading").optional().describe("Only match this role: button, link, tab, menuitem, textbox, combobox, switch, heading, region…"),
|
|
878
|
+
nth: z.number().int().min(1).max(20).optional().describe("Which match to use, 1-based, when several elements match (the error lists them)."),
|
|
879
|
+
label: z.string().trim().min(1).max(200).describe("Short plain text shown beside it, e.g. Click here to cancel the run. No markdown.")
|
|
880
|
+
}),
|
|
881
|
+
annotations: {
|
|
882
|
+
readOnly: true,
|
|
883
|
+
untrustedContent: true
|
|
884
|
+
},
|
|
885
|
+
execute: async ({ name, role, nth, label }, { signal }) => {
|
|
886
|
+
const matches = await waitForMatches(root, role ? {
|
|
887
|
+
name,
|
|
888
|
+
role
|
|
889
|
+
} : { name }, waitMs, signal);
|
|
890
|
+
if (matches.length === 0) throw new AgentToolError("NOT_FOUND", `Nothing named "${name.slice(0, 60)}"${role ? ` with role ${role}` : ""} is visible on this page. Open the right page (app_navigate) or check names with ui_snapshot.`, { retryable: true });
|
|
891
|
+
const chosen = pick(matches, nth);
|
|
892
|
+
const scope = root();
|
|
893
|
+
const callout = useAgentMarksStore.getState().actions.addCallout({
|
|
894
|
+
label,
|
|
895
|
+
element: chosen.element,
|
|
896
|
+
route: chosen.element.ownerDocument.defaultView?.location.pathname ?? "",
|
|
897
|
+
target: {
|
|
898
|
+
role: chosen.role,
|
|
899
|
+
name: chosen.name,
|
|
900
|
+
nth: scope ? positionAmongTwins(scope, chosen) : 1
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
chosen.element.scrollIntoView?.({
|
|
904
|
+
block: "center",
|
|
905
|
+
inline: "nearest"
|
|
906
|
+
});
|
|
907
|
+
return {
|
|
908
|
+
shown: true,
|
|
909
|
+
pointedAt: {
|
|
910
|
+
role: chosen.role,
|
|
911
|
+
name: chosen.name
|
|
912
|
+
},
|
|
913
|
+
calloutId: callout.id,
|
|
914
|
+
...matches.length > 1 ? { otherMatches: matches.length - 1 } : {}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}), defineAgentTool({
|
|
918
|
+
name: "fg_ui_clear_marks",
|
|
919
|
+
title: "Clear the assistant's marks",
|
|
920
|
+
description: "Removes the callouts you drew with fg_ui_point_at and, when asked, the notes you pinned on workflow nodes (all workflows). Only when the person asks to clear them or a callout no longer applies.",
|
|
921
|
+
input: z.strictObject({ which: z.enum([
|
|
922
|
+
"callouts",
|
|
923
|
+
"notes",
|
|
924
|
+
"all"
|
|
925
|
+
]).default("callouts").describe("callouts (default), notes, or all.") }),
|
|
926
|
+
annotations: { readOnly: true },
|
|
927
|
+
execute: ({ which }) => {
|
|
928
|
+
const { actions } = useAgentMarksStore.getState();
|
|
929
|
+
return {
|
|
930
|
+
clearedCallouts: which === "notes" ? 0 : actions.clearCallouts(),
|
|
931
|
+
clearedNotes: which === "callouts" ? 0 : actions.clearNotes()
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
})];
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export { triggerNodesOf as a, pickTriggerNode as i, AgentCalloutOverlay as n, createFluidGridsAgentTools as r, createUiMarkTools as t };
|