@ema.co/mcp-toolkit 0.2.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/LICENSE +21 -0
- package/README.md +321 -0
- package/config.example.yaml +32 -0
- package/dist/cli/index.js +333 -0
- package/dist/config.js +136 -0
- package/dist/emaClient.js +398 -0
- package/dist/index.js +109 -0
- package/dist/mcp/handlers-consolidated.js +851 -0
- package/dist/mcp/index.js +15 -0
- package/dist/mcp/prompts.js +1753 -0
- package/dist/mcp/resources.js +624 -0
- package/dist/mcp/server.js +4723 -0
- package/dist/mcp/tools-consolidated.js +590 -0
- package/dist/mcp/tools-legacy.js +736 -0
- package/dist/models.js +8 -0
- package/dist/scheduler.js +21 -0
- package/dist/sdk/client.js +788 -0
- package/dist/sdk/config.js +136 -0
- package/dist/sdk/contracts.js +429 -0
- package/dist/sdk/generation-schema.js +189 -0
- package/dist/sdk/index.js +39 -0
- package/dist/sdk/knowledge.js +2780 -0
- package/dist/sdk/models.js +8 -0
- package/dist/sdk/state.js +88 -0
- package/dist/sdk/sync-options.js +216 -0
- package/dist/sdk/sync.js +220 -0
- package/dist/sdk/validation-rules.js +355 -0
- package/dist/sdk/workflow-generator.js +291 -0
- package/dist/sdk/workflow-intent.js +1585 -0
- package/dist/state.js +88 -0
- package/dist/sync.js +416 -0
- package/dist/syncOptions.js +216 -0
- package/dist/ui.js +334 -0
- package/docs/advisor-comms-assistant-fixes.md +175 -0
- package/docs/api-contracts.md +216 -0
- package/docs/auto-builder-analysis.md +271 -0
- package/docs/data-architecture.md +166 -0
- package/docs/ema-auto-builder-guide.html +394 -0
- package/docs/ema-user-guide.md +1121 -0
- package/docs/mcp-tools-guide.md +149 -0
- package/docs/naming-conventions.md +218 -0
- package/docs/tool-consolidation-proposal.md +427 -0
- package/package.json +98 -0
- package/resources/templates/chat-ai/README.md +119 -0
- package/resources/templates/chat-ai/persona-config.json +111 -0
- package/resources/templates/dashboard-ai/README.md +156 -0
- package/resources/templates/dashboard-ai/persona-config.json +180 -0
- package/resources/templates/voice-ai/README.md +123 -0
- package/resources/templates/voice-ai/persona-config.json +74 -0
- package/resources/templates/voice-ai/workflow-prompt.md +120 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
export class EmaApiError extends Error {
|
|
2
|
+
statusCode;
|
|
3
|
+
body;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
super(opts.message);
|
|
6
|
+
this.statusCode = opts.statusCode;
|
|
7
|
+
this.body = opts.body;
|
|
8
|
+
this.name = "EmaApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
export class EmaClient {
|
|
15
|
+
env;
|
|
16
|
+
timeoutMs;
|
|
17
|
+
tokenRefreshConfig;
|
|
18
|
+
refreshIntervalId;
|
|
19
|
+
isRefreshing = false;
|
|
20
|
+
constructor(env, opts) {
|
|
21
|
+
this.env = env;
|
|
22
|
+
this.timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
23
|
+
this.tokenRefreshConfig = opts?.tokenRefresh;
|
|
24
|
+
// Start background refresh if configured
|
|
25
|
+
if (this.tokenRefreshConfig?.backgroundRefresh !== false && this.tokenRefreshConfig?.refreshCallback) {
|
|
26
|
+
this.startBackgroundRefresh();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start background token refresh.
|
|
31
|
+
* Refreshes tokens proactively before expiration.
|
|
32
|
+
*/
|
|
33
|
+
startBackgroundRefresh() {
|
|
34
|
+
if (this.refreshIntervalId)
|
|
35
|
+
return;
|
|
36
|
+
const intervalMs = this.tokenRefreshConfig?.refreshIntervalMs ?? 50 * 60 * 1000; // 50 minutes default
|
|
37
|
+
this.refreshIntervalId = setInterval(async () => {
|
|
38
|
+
await this.refreshToken();
|
|
39
|
+
}, intervalMs);
|
|
40
|
+
// Don't prevent process exit
|
|
41
|
+
if (this.refreshIntervalId.unref) {
|
|
42
|
+
this.refreshIntervalId.unref();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stop background token refresh.
|
|
47
|
+
*/
|
|
48
|
+
stopBackgroundRefresh() {
|
|
49
|
+
if (this.refreshIntervalId) {
|
|
50
|
+
clearInterval(this.refreshIntervalId);
|
|
51
|
+
this.refreshIntervalId = undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Manually refresh the token.
|
|
56
|
+
* Returns true if refresh succeeded.
|
|
57
|
+
*/
|
|
58
|
+
async refreshToken() {
|
|
59
|
+
if (!this.tokenRefreshConfig?.refreshCallback)
|
|
60
|
+
return false;
|
|
61
|
+
if (this.isRefreshing)
|
|
62
|
+
return false;
|
|
63
|
+
this.isRefreshing = true;
|
|
64
|
+
try {
|
|
65
|
+
const newToken = await this.tokenRefreshConfig.refreshCallback();
|
|
66
|
+
if (newToken) {
|
|
67
|
+
this.env = { ...this.env, bearerToken: newToken };
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
this.isRefreshing = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Update the bearer token directly.
|
|
81
|
+
*/
|
|
82
|
+
updateToken(newToken) {
|
|
83
|
+
this.env = { ...this.env, bearerToken: newToken };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get the current environment (read-only).
|
|
87
|
+
*/
|
|
88
|
+
getEnvironment() {
|
|
89
|
+
return this.env;
|
|
90
|
+
}
|
|
91
|
+
async requestWithRetries(method, path, opts) {
|
|
92
|
+
const retries = opts?.retries ?? 4;
|
|
93
|
+
const baseDelayMs = opts?.baseDelayMs ?? 500;
|
|
94
|
+
let hasAttemptedRefresh = false;
|
|
95
|
+
let lastErr;
|
|
96
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
97
|
+
try {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
100
|
+
const fullUrl = `${this.env.baseUrl.replace(/\/$/, "")}${path}`;
|
|
101
|
+
const response = await fetch(fullUrl, {
|
|
102
|
+
method,
|
|
103
|
+
headers: {
|
|
104
|
+
Authorization: `Bearer ${this.env.bearerToken}`,
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
},
|
|
107
|
+
body: opts?.json !== undefined ? JSON.stringify(opts.json) : undefined,
|
|
108
|
+
signal: controller.signal,
|
|
109
|
+
});
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
// Handle 401 - attempt token refresh once
|
|
112
|
+
if (response.status === 401 && !hasAttemptedRefresh && this.tokenRefreshConfig?.refreshCallback) {
|
|
113
|
+
hasAttemptedRefresh = true;
|
|
114
|
+
const refreshed = await this.refreshToken();
|
|
115
|
+
if (refreshed) {
|
|
116
|
+
// Retry immediately with new token
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Retry on transient errors
|
|
121
|
+
if ([429, 500, 502, 503, 504].includes(response.status) && attempt < retries) {
|
|
122
|
+
const delay = baseDelayMs * 2 ** attempt;
|
|
123
|
+
await sleep(delay);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
130
|
+
if (attempt >= retries)
|
|
131
|
+
throw lastErr;
|
|
132
|
+
const delay = baseDelayMs * 2 ** attempt;
|
|
133
|
+
await sleep(delay);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw lastErr ?? new Error("Unreachable");
|
|
137
|
+
}
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// AI Employees (Personas)
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
async getPersonasForTenant() {
|
|
142
|
+
// Try POST first (original API), fall back to GET if 405
|
|
143
|
+
let resp = await this.requestWithRetries("POST", "/api/personas/get_personas_for_tenant", {
|
|
144
|
+
json: {},
|
|
145
|
+
});
|
|
146
|
+
// If POST fails with 405, try GET method
|
|
147
|
+
if (resp.status === 405) {
|
|
148
|
+
resp = await this.requestWithRetries("GET", "/api/personas/get_personas_for_tenant", {});
|
|
149
|
+
}
|
|
150
|
+
if (!resp.ok) {
|
|
151
|
+
throw new EmaApiError({
|
|
152
|
+
statusCode: resp.status,
|
|
153
|
+
body: await resp.text(),
|
|
154
|
+
message: `get_personas_for_tenant failed (${this.env.name})`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const data = (await resp.json());
|
|
158
|
+
const personas = data.personas ?? data.configs ?? [];
|
|
159
|
+
return personas;
|
|
160
|
+
}
|
|
161
|
+
/** Alias: in the UI, personas are called "AI Employees" */
|
|
162
|
+
async getAiEmployeesForTenant() {
|
|
163
|
+
return this.getPersonasForTenant();
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get a single persona by ID with full details including workflow_def.
|
|
167
|
+
* Uses /api/personas/{id} endpoint which returns the complete persona including workflow_def.
|
|
168
|
+
*/
|
|
169
|
+
async getPersonaById(personaId) {
|
|
170
|
+
// Primary: /api/personas/{id} - returns full persona with workflow_def
|
|
171
|
+
// Note: get_minimal_persona=false is REQUIRED to get workflow_def in response
|
|
172
|
+
try {
|
|
173
|
+
const resp = await this.requestWithRetries("GET", `/api/personas/${personaId}?get_minimal_persona=false`, {});
|
|
174
|
+
if (resp.ok) {
|
|
175
|
+
return (await resp.json());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Fall through to fallback
|
|
180
|
+
}
|
|
181
|
+
// Fallback: /api/ai_employee/get_ai_employee endpoint
|
|
182
|
+
try {
|
|
183
|
+
const resp = await this.requestWithRetries("GET", `/api/ai_employee/get_ai_employee?persona_id=${personaId}`, {});
|
|
184
|
+
if (resp.ok) {
|
|
185
|
+
const data = (await resp.json());
|
|
186
|
+
return data.persona ?? data.ai_employee ?? data.config ?? null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Fall through to return null
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get workflow definition by workflow_id.
|
|
196
|
+
* Tries multiple API endpoints to fetch the workflow.
|
|
197
|
+
*/
|
|
198
|
+
async getWorkflowDef(workflowId) {
|
|
199
|
+
// Try gRPC-web style GetWorkflow endpoint
|
|
200
|
+
try {
|
|
201
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflow", {
|
|
202
|
+
json: { workflow_id: workflowId },
|
|
203
|
+
});
|
|
204
|
+
if (resp.ok) {
|
|
205
|
+
const data = (await resp.json());
|
|
206
|
+
return data.workflow ?? (Object.keys(data).length > 0 ? data : null);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Fall through
|
|
211
|
+
}
|
|
212
|
+
// Try GetWorkflowDefinition endpoint (alternate name)
|
|
213
|
+
try {
|
|
214
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflowDefinition", {
|
|
215
|
+
json: { workflow_id: workflowId },
|
|
216
|
+
});
|
|
217
|
+
if (resp.ok) {
|
|
218
|
+
const data = (await resp.json());
|
|
219
|
+
return data;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Fall through
|
|
224
|
+
}
|
|
225
|
+
// Try listing actions and constructing workflow
|
|
226
|
+
try {
|
|
227
|
+
const actions = await this.listActionsFromWorkflow(workflowId);
|
|
228
|
+
if (actions && actions.length > 0) {
|
|
229
|
+
// Return actions as part of workflow def (partial reconstruction)
|
|
230
|
+
return { workflow_id: workflowId, actions: actions };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Fall through
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
async updateAiEmployee(req, opts) {
|
|
239
|
+
// Use /api/personas/update_persona - same endpoint as Ema frontend
|
|
240
|
+
const resp = await this.requestWithRetries("POST", "/api/personas/update_persona", {
|
|
241
|
+
json: req,
|
|
242
|
+
});
|
|
243
|
+
if (!resp.ok) {
|
|
244
|
+
const body = await resp.text();
|
|
245
|
+
// Try to parse error body for better error messages
|
|
246
|
+
let errorDetail = "";
|
|
247
|
+
try {
|
|
248
|
+
const errorJson = JSON.parse(body);
|
|
249
|
+
// Ema API returns InvalidPersonaResponse with 'detail' field
|
|
250
|
+
errorDetail = errorJson.detail ?? errorJson.message ?? errorJson.error ?? "";
|
|
251
|
+
// Also check for GWE (workflow) issues
|
|
252
|
+
if (errorJson.gwe_issues) {
|
|
253
|
+
const issues = errorJson.gwe_issues;
|
|
254
|
+
if (Array.isArray(issues)) {
|
|
255
|
+
errorDetail += ` Workflow issues: ${issues.map((i) => i.message ?? i.detail ?? JSON.stringify(i)).join("; ")}`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Body is not JSON, use as-is
|
|
261
|
+
errorDetail = body;
|
|
262
|
+
}
|
|
263
|
+
throw new EmaApiError({
|
|
264
|
+
statusCode: resp.status,
|
|
265
|
+
body,
|
|
266
|
+
message: errorDetail
|
|
267
|
+
? `update_ai_employee failed (${this.env.name}): ${errorDetail}`
|
|
268
|
+
: `update_ai_employee failed (${this.env.name}) - status ${resp.status}`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return (await resp.json());
|
|
272
|
+
}
|
|
273
|
+
async createAiEmployee(req, opts) {
|
|
274
|
+
const resp = await this.requestWithRetries("POST", "/api/ai_employee/create_ai_employee", {
|
|
275
|
+
json: req,
|
|
276
|
+
});
|
|
277
|
+
if (!resp.ok) {
|
|
278
|
+
const body = await resp.text();
|
|
279
|
+
throw new EmaApiError({
|
|
280
|
+
statusCode: resp.status,
|
|
281
|
+
body,
|
|
282
|
+
message: `create_ai_employee failed (${this.env.name})`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return (await resp.json());
|
|
286
|
+
}
|
|
287
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
// Data Source Management
|
|
289
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
290
|
+
/**
|
|
291
|
+
* Upload a file to an AI Employee's knowledge base.
|
|
292
|
+
* API endpoint: POST /api/v2/upload/files (multipart/form-data)
|
|
293
|
+
*/
|
|
294
|
+
async uploadDataSource(personaId, fileContent, filename, opts) {
|
|
295
|
+
const widgetName = opts?.widgetName ?? "fileUpload";
|
|
296
|
+
const tags = opts?.tags ?? widgetName;
|
|
297
|
+
const mimeType = opts?.mimeType ?? this.detectMimeType(filename);
|
|
298
|
+
// Build multipart form data manually
|
|
299
|
+
const boundary = `----EmaUploadBoundary${Date.now()}`;
|
|
300
|
+
const parts = [];
|
|
301
|
+
// persona_id field
|
|
302
|
+
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="persona_id"\r\n\r\n${personaId}`);
|
|
303
|
+
// tags field
|
|
304
|
+
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="tags"\r\n\r\n${tags}`);
|
|
305
|
+
// widget_name field
|
|
306
|
+
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="widget_name"\r\n\r\n${widgetName}`);
|
|
307
|
+
// Encode file content parts separately to handle binary data
|
|
308
|
+
const textEncoder = new TextEncoder();
|
|
309
|
+
const headerPart = textEncoder.encode(parts.join("\r\n") + "\r\n" +
|
|
310
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="files-0"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
|
|
311
|
+
const filenamePart = textEncoder.encode(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="files-0"\r\n\r\n${filename}`);
|
|
312
|
+
const pathPart = textEncoder.encode(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="files-0-path"\r\n\r\n`);
|
|
313
|
+
const closingPart = textEncoder.encode(`\r\n--${boundary}--\r\n`);
|
|
314
|
+
// Combine all parts
|
|
315
|
+
const totalLength = headerPart.length + fileContent.length + filenamePart.length + pathPart.length + closingPart.length;
|
|
316
|
+
const body = new Uint8Array(totalLength);
|
|
317
|
+
let offset = 0;
|
|
318
|
+
body.set(headerPart, offset);
|
|
319
|
+
offset += headerPart.length;
|
|
320
|
+
body.set(fileContent, offset);
|
|
321
|
+
offset += fileContent.length;
|
|
322
|
+
body.set(filenamePart, offset);
|
|
323
|
+
offset += filenamePart.length;
|
|
324
|
+
body.set(pathPart, offset);
|
|
325
|
+
offset += pathPart.length;
|
|
326
|
+
body.set(closingPart, offset);
|
|
327
|
+
const controller = new AbortController();
|
|
328
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs * 2); // Longer timeout for uploads
|
|
329
|
+
try {
|
|
330
|
+
const resp = await fetch(`${this.env.baseUrl.replace(/\/$/, "")}/api/v2/upload/files`, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
Authorization: `Bearer ${this.env.bearerToken}`,
|
|
334
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
335
|
+
"X-Persona-Id": personaId,
|
|
336
|
+
},
|
|
337
|
+
body: body,
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
});
|
|
340
|
+
clearTimeout(timeoutId);
|
|
341
|
+
if (!resp.ok) {
|
|
342
|
+
const errorBody = await resp.text();
|
|
343
|
+
throw new EmaApiError({
|
|
344
|
+
statusCode: resp.status,
|
|
345
|
+
body: errorBody,
|
|
346
|
+
message: `uploadDataSource failed (${this.env.name})`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const result = await resp.json();
|
|
350
|
+
return {
|
|
351
|
+
fileId: result.file_id ?? result.id ?? "",
|
|
352
|
+
status: result.status ?? "uploaded",
|
|
353
|
+
filename: filename,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
clearTimeout(timeoutId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Delete a data source file from an AI Employee's knowledge base.
|
|
362
|
+
* API endpoint: DELETE /api/v2/upload/files/{file_id} or via persona update
|
|
363
|
+
*/
|
|
364
|
+
async deleteDataSource(personaId, fileId) {
|
|
365
|
+
// Try direct delete endpoint first
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
368
|
+
try {
|
|
369
|
+
const resp = await fetch(`${this.env.baseUrl.replace(/\/$/, "")}/api/v2/upload/files/${fileId}?persona_id=${personaId}`, {
|
|
370
|
+
method: "DELETE",
|
|
371
|
+
headers: {
|
|
372
|
+
Authorization: `Bearer ${this.env.bearerToken}`,
|
|
373
|
+
"X-Persona-Id": personaId,
|
|
374
|
+
},
|
|
375
|
+
signal: controller.signal,
|
|
376
|
+
});
|
|
377
|
+
clearTimeout(timeoutId);
|
|
378
|
+
if (!resp.ok && resp.status !== 404) {
|
|
379
|
+
const errorBody = await resp.text();
|
|
380
|
+
throw new EmaApiError({
|
|
381
|
+
statusCode: resp.status,
|
|
382
|
+
body: errorBody,
|
|
383
|
+
message: `deleteDataSource failed (${this.env.name})`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return { success: true, fileId };
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
clearTimeout(timeoutId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* List data source files for an AI Employee.
|
|
394
|
+
* Uses the DataIngestService gRPC endpoint.
|
|
395
|
+
*/
|
|
396
|
+
async listDataSourceFiles(personaId) {
|
|
397
|
+
// Try the content aggregates endpoint (may return limited info)
|
|
398
|
+
try {
|
|
399
|
+
const resp = await this.requestWithRetries("POST", "/dataingest.v1.DataIngestService/GetContentNodeAggregates", {
|
|
400
|
+
json: {
|
|
401
|
+
widget_name: "fileUpload",
|
|
402
|
+
include_files: true,
|
|
403
|
+
persona_id: personaId,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
if (resp.ok) {
|
|
407
|
+
const data = await resp.json();
|
|
408
|
+
return (data.files ?? []).map((f) => ({
|
|
409
|
+
id: f.id,
|
|
410
|
+
filename: f.name,
|
|
411
|
+
status: f.status ?? "unknown",
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// Fall through to return empty
|
|
417
|
+
}
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Detect MIME type from filename extension.
|
|
422
|
+
*/
|
|
423
|
+
detectMimeType(filename) {
|
|
424
|
+
const ext = filename.toLowerCase().split(".").pop() ?? "";
|
|
425
|
+
const mimeTypes = {
|
|
426
|
+
pdf: "application/pdf",
|
|
427
|
+
doc: "application/msword",
|
|
428
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
429
|
+
txt: "text/plain",
|
|
430
|
+
md: "text/markdown",
|
|
431
|
+
csv: "text/csv",
|
|
432
|
+
json: "application/json",
|
|
433
|
+
xml: "application/xml",
|
|
434
|
+
html: "text/html",
|
|
435
|
+
htm: "text/html",
|
|
436
|
+
xls: "application/vnd.ms-excel",
|
|
437
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
438
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
439
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
440
|
+
png: "image/png",
|
|
441
|
+
jpg: "image/jpeg",
|
|
442
|
+
jpeg: "image/jpeg",
|
|
443
|
+
gif: "image/gif",
|
|
444
|
+
svg: "image/svg+xml",
|
|
445
|
+
};
|
|
446
|
+
return mimeTypes[ext] ?? "application/octet-stream";
|
|
447
|
+
}
|
|
448
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
449
|
+
// Actions (displayed as "Agents" in the UI)
|
|
450
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
451
|
+
/**
|
|
452
|
+
* List all actions available to the tenant.
|
|
453
|
+
* API endpoint: workflows.v1.ActionManager/ListActions
|
|
454
|
+
* UI label: "Agents"
|
|
455
|
+
*/
|
|
456
|
+
async listActions() {
|
|
457
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActions", {
|
|
458
|
+
json: {},
|
|
459
|
+
});
|
|
460
|
+
if (!resp.ok) {
|
|
461
|
+
throw new EmaApiError({
|
|
462
|
+
statusCode: resp.status,
|
|
463
|
+
body: await resp.text(),
|
|
464
|
+
message: `listActions failed (${this.env.name})`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const data = (await resp.json());
|
|
468
|
+
return data.actions ?? [];
|
|
469
|
+
}
|
|
470
|
+
/** Alias: in the UI, actions are called "Agents" */
|
|
471
|
+
async listAgents() {
|
|
472
|
+
return this.listActions();
|
|
473
|
+
}
|
|
474
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
475
|
+
// Persona Templates
|
|
476
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
477
|
+
/**
|
|
478
|
+
* List persona templates available to the current tenant.
|
|
479
|
+
* API endpoint: /api/personas/get_persona_templates
|
|
480
|
+
*
|
|
481
|
+
* Returns system templates (chatbot_starter, voicebot_ai_employee, etc.)
|
|
482
|
+
* that define pre-configured AI Employee configurations.
|
|
483
|
+
*/
|
|
484
|
+
async getPersonaTemplates() {
|
|
485
|
+
// Try GET first (newer API), fall back to POST
|
|
486
|
+
let resp = await this.requestWithRetries("GET", "/api/personas/get_persona_templates", {});
|
|
487
|
+
if (resp.status === 405) {
|
|
488
|
+
// Fall back to POST
|
|
489
|
+
resp = await this.requestWithRetries("POST", "/api/personas/get_persona_templates", {
|
|
490
|
+
json: {},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (!resp.ok) {
|
|
494
|
+
throw new EmaApiError({
|
|
495
|
+
statusCode: resp.status,
|
|
496
|
+
body: await resp.text(),
|
|
497
|
+
message: `getPersonaTemplates failed (${this.env.name})`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const data = (await resp.json());
|
|
501
|
+
return data.templates ?? [];
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Get a specific persona template by ID.
|
|
505
|
+
*
|
|
506
|
+
* @param templateId - The template ID to fetch
|
|
507
|
+
*/
|
|
508
|
+
async getPersonaTemplateById(templateId) {
|
|
509
|
+
const templates = await this.getPersonaTemplates();
|
|
510
|
+
return templates.find((t) => t.id === templateId) ?? null;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* List actions associated with a specific workflow.
|
|
514
|
+
* API endpoint: workflows.v1.ActionManager/ListActionsFromWorkflow
|
|
515
|
+
* UI label: "Agents"
|
|
516
|
+
*
|
|
517
|
+
* @param workflowId - The workflow ID to list actions for
|
|
518
|
+
*/
|
|
519
|
+
async listActionsFromWorkflow(workflowId) {
|
|
520
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActionsFromWorkflow", { json: { workflow_id: workflowId } });
|
|
521
|
+
if (!resp.ok) {
|
|
522
|
+
throw new EmaApiError({
|
|
523
|
+
statusCode: resp.status,
|
|
524
|
+
body: await resp.text(),
|
|
525
|
+
message: `listActionsFromWorkflow failed (${this.env.name})`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const data = (await resp.json());
|
|
529
|
+
return data.actions ?? [];
|
|
530
|
+
}
|
|
531
|
+
/** Alias: in the UI, actions are called "Agents" */
|
|
532
|
+
async listAgentsFromWorkflow(workflowId) {
|
|
533
|
+
return this.listActionsFromWorkflow(workflowId);
|
|
534
|
+
}
|
|
535
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
536
|
+
// Sync Metadata (tagging synced personas)
|
|
537
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
538
|
+
/**
|
|
539
|
+
* Sync tag regex patterns.
|
|
540
|
+
* Current format: <!-- synced_from:env/id -->
|
|
541
|
+
* Legacy formats also supported for backward compatibility.
|
|
542
|
+
*/
|
|
543
|
+
static SYNC_TAG_REGEX = /<!-- synced_from:([^/]+)\/([a-f0-9-]+) -->/;
|
|
544
|
+
static LEGACY_JSON_REGEX = /<!-- _ema_sync:(.+?) -->/;
|
|
545
|
+
/**
|
|
546
|
+
* Get sync metadata from a persona's description.
|
|
547
|
+
* Returns null if the persona has never been synced.
|
|
548
|
+
*/
|
|
549
|
+
getSyncMetadata(persona) {
|
|
550
|
+
// Check top-level description for sync tag
|
|
551
|
+
const desc = persona.description;
|
|
552
|
+
if (desc) {
|
|
553
|
+
const meta = this.extractSyncMetadataFromString(desc);
|
|
554
|
+
if (meta)
|
|
555
|
+
return meta;
|
|
556
|
+
}
|
|
557
|
+
// Fallback: check proto_config._ema_sync (legacy)
|
|
558
|
+
const protoConfig = persona.proto_config;
|
|
559
|
+
if (protoConfig?._ema_sync) {
|
|
560
|
+
return protoConfig._ema_sync;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Extract sync metadata from a string (description field).
|
|
566
|
+
* Supports both new compact format and legacy JSON format.
|
|
567
|
+
*/
|
|
568
|
+
extractSyncMetadataFromString(text) {
|
|
569
|
+
// Try new compact format: <!-- synced_from:env/id -->
|
|
570
|
+
const compactMatch = text.match(EmaClient.SYNC_TAG_REGEX);
|
|
571
|
+
if (compactMatch) {
|
|
572
|
+
return {
|
|
573
|
+
master_env: compactMatch[1],
|
|
574
|
+
master_id: compactMatch[2],
|
|
575
|
+
synced_at: new Date().toISOString(),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// Try legacy JSON format: <!-- _ema_sync:{...} -->
|
|
579
|
+
const legacyMatch = text.match(EmaClient.LEGACY_JSON_REGEX);
|
|
580
|
+
if (legacyMatch) {
|
|
581
|
+
try {
|
|
582
|
+
return JSON.parse(legacyMatch[1]);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Check if a persona was synced from a master environment.
|
|
592
|
+
*/
|
|
593
|
+
isSyncedPersona(persona) {
|
|
594
|
+
return this.getSyncMetadata(persona) !== null;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Regex to clean all sync tag formats from description.
|
|
598
|
+
*/
|
|
599
|
+
static SYNC_CLEANUP_REGEX = /<!-- (?:synced_from|sync|_ema_sync):[^\n]*-->/g;
|
|
600
|
+
/**
|
|
601
|
+
* Extract the clean description (without sync marker).
|
|
602
|
+
*/
|
|
603
|
+
getCleanDescription(description) {
|
|
604
|
+
if (!description)
|
|
605
|
+
return "";
|
|
606
|
+
return description.replace(EmaClient.SYNC_CLEANUP_REGEX, "").trim();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Build description with compact sync tag appended.
|
|
610
|
+
*/
|
|
611
|
+
buildDescriptionWithSyncTag(cleanDescription, metadata) {
|
|
612
|
+
const marker = `<!-- synced_from:${metadata.master_env}/${metadata.master_id} -->`;
|
|
613
|
+
return cleanDescription ? `${cleanDescription}\n\n${marker}` : marker;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Tag a persona as synced by appending metadata to description.
|
|
617
|
+
*/
|
|
618
|
+
async tagAsSynced(personaId, metadata, currentDescription, existingProtoConfig) {
|
|
619
|
+
const cleanDesc = this.getCleanDescription(currentDescription);
|
|
620
|
+
const newDescription = this.buildDescriptionWithSyncTag(cleanDesc, metadata);
|
|
621
|
+
await this.updateAiEmployee({
|
|
622
|
+
persona_id: personaId,
|
|
623
|
+
proto_config: existingProtoConfig ?? {},
|
|
624
|
+
description: newDescription,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Remove sync metadata from a persona (unlink from master).
|
|
629
|
+
*/
|
|
630
|
+
async removeSyncTag(personaId, currentDescription, existingProtoConfig) {
|
|
631
|
+
const cleanDesc = this.getCleanDescription(currentDescription);
|
|
632
|
+
await this.updateAiEmployee({
|
|
633
|
+
persona_id: personaId,
|
|
634
|
+
proto_config: existingProtoConfig ?? {},
|
|
635
|
+
description: cleanDesc,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* List all personas that have sync metadata (were synced from master).
|
|
640
|
+
*/
|
|
641
|
+
async listSyncedPersonas() {
|
|
642
|
+
const personas = await this.getPersonasForTenant();
|
|
643
|
+
const synced = [];
|
|
644
|
+
for (const p of personas) {
|
|
645
|
+
const meta = this.getSyncMetadata(p);
|
|
646
|
+
if (meta) {
|
|
647
|
+
synced.push({ persona: p, syncMetadata: meta });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return synced;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Find a synced persona by its master environment and master ID.
|
|
654
|
+
*/
|
|
655
|
+
async findSyncedPersona(masterEnv, masterId) {
|
|
656
|
+
const synced = await this.listSyncedPersonas();
|
|
657
|
+
return (synced.find((s) => s.syncMetadata.master_env === masterEnv && s.syncMetadata.master_id === masterId) ?? null);
|
|
658
|
+
}
|
|
659
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
660
|
+
// Autobuilder Chat API
|
|
661
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
662
|
+
// Cache for discovered Autobuilder persona
|
|
663
|
+
autobuilderPersonaId = null;
|
|
664
|
+
// Cache for active conversation per target persona
|
|
665
|
+
autobuilderConversations = new Map();
|
|
666
|
+
/**
|
|
667
|
+
* Auto-discover the Autobuilder persona in the tenant.
|
|
668
|
+
* Looks for personas with template_id matching the Autobuilder template.
|
|
669
|
+
*/
|
|
670
|
+
async findAutobuilderPersona() {
|
|
671
|
+
if (this.autobuilderPersonaId) {
|
|
672
|
+
return this.autobuilderPersonaId;
|
|
673
|
+
}
|
|
674
|
+
// Known Autobuilder template IDs
|
|
675
|
+
const AUTOBUILDER_TEMPLATE_IDS = [
|
|
676
|
+
"00000000-0000-0000-0000-000000000014", // Ema Auto Builder
|
|
677
|
+
"00000000-0000-0000-0000-00000000000a", // Autobuilder for X
|
|
678
|
+
];
|
|
679
|
+
const allPersonas = await this.getPersonasForTenant();
|
|
680
|
+
// First, look for the main "Ema Auto Builder" by name and template
|
|
681
|
+
const mainAutobuilder = allPersonas.find(p => p.name === "Ema Auto Builder" &&
|
|
682
|
+
AUTOBUILDER_TEMPLATE_IDS.includes(p.template_id ?? ""));
|
|
683
|
+
if (mainAutobuilder) {
|
|
684
|
+
this.autobuilderPersonaId = mainAutobuilder.id;
|
|
685
|
+
return mainAutobuilder.id;
|
|
686
|
+
}
|
|
687
|
+
// Fallback: any persona with Autobuilder template
|
|
688
|
+
const anyAutobuilder = allPersonas.find(p => AUTOBUILDER_TEMPLATE_IDS.includes(p.template_id ?? ""));
|
|
689
|
+
if (anyAutobuilder) {
|
|
690
|
+
this.autobuilderPersonaId = anyAutobuilder.id;
|
|
691
|
+
return anyAutobuilder.id;
|
|
692
|
+
}
|
|
693
|
+
throw new EmaApiError({
|
|
694
|
+
statusCode: 404,
|
|
695
|
+
message: `No Autobuilder persona found in tenant (${this.env.name}). Please contact your Ema administrator.`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Create a new conversation with the Autobuilder.
|
|
700
|
+
* API endpoint: POST /api/conversations/create?persona_id=<autobuilder_id>
|
|
701
|
+
*/
|
|
702
|
+
async createAutobuilderConversation(autobuilderPersonaId) {
|
|
703
|
+
const resp = await this.requestWithRetries("POST", `/api/conversations/create?persona_id=${autobuilderPersonaId}`, {});
|
|
704
|
+
if (!resp.ok) {
|
|
705
|
+
const body = await resp.text();
|
|
706
|
+
throw new EmaApiError({
|
|
707
|
+
statusCode: resp.status,
|
|
708
|
+
body,
|
|
709
|
+
message: `create_autobuilder_conversation failed (${this.env.name})`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const result = await resp.json();
|
|
713
|
+
return { conversation_id: String(result.conversation_id ?? result.id ?? "") };
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Send a message to the Autobuilder chat.
|
|
717
|
+
* API endpoint: POST /api/web/
|
|
718
|
+
*
|
|
719
|
+
* @param conversationId - The conversation ID from createAutobuilderConversation
|
|
720
|
+
* @param prompt - The message to send (e.g., "test", "add error handling", etc.)
|
|
721
|
+
* @param workflowDef - Optional workflow definition to iterate on
|
|
722
|
+
*/
|
|
723
|
+
async chatWithAutobuilder(conversationId, prompt, workflowDef) {
|
|
724
|
+
const body = {
|
|
725
|
+
prompt,
|
|
726
|
+
conversation_id: conversationId,
|
|
727
|
+
};
|
|
728
|
+
if (workflowDef) {
|
|
729
|
+
body.workflow_def = workflowDef;
|
|
730
|
+
}
|
|
731
|
+
const resp = await this.requestWithRetries("POST", "/api/web/", { json: body });
|
|
732
|
+
if (!resp.ok) {
|
|
733
|
+
const errorBody = await resp.text();
|
|
734
|
+
throw new EmaApiError({
|
|
735
|
+
statusCode: resp.status,
|
|
736
|
+
body: errorBody,
|
|
737
|
+
message: `chat_with_autobuilder failed (${this.env.name})`,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
const result = await resp.json();
|
|
741
|
+
return {
|
|
742
|
+
response: String(result.response ?? result.message ?? ""),
|
|
743
|
+
workflow_def: result.workflow_def,
|
|
744
|
+
conversation_id: conversationId,
|
|
745
|
+
raw: result,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* High-level: Iterate on a persona's workflow using the Autobuilder.
|
|
750
|
+
* Abstracts away Autobuilder discovery and conversation management.
|
|
751
|
+
*
|
|
752
|
+
* @param personaId - The target AI Employee whose workflow to iterate on
|
|
753
|
+
* @param instruction - Natural language instruction (e.g., "add error handling", "optimize search")
|
|
754
|
+
* @param opts.newConversation - Force create a new conversation (default: reuse existing)
|
|
755
|
+
*/
|
|
756
|
+
async iterateWorkflow(personaId, instruction, opts) {
|
|
757
|
+
// 1. Auto-discover Autobuilder
|
|
758
|
+
const autobuilderId = await this.findAutobuilderPersona();
|
|
759
|
+
// 2. Get or create conversation for this persona
|
|
760
|
+
let conversationId = this.autobuilderConversations.get(personaId);
|
|
761
|
+
if (!conversationId || opts?.newConversation) {
|
|
762
|
+
const conv = await this.createAutobuilderConversation(autobuilderId);
|
|
763
|
+
conversationId = conv.conversation_id;
|
|
764
|
+
this.autobuilderConversations.set(personaId, conversationId);
|
|
765
|
+
}
|
|
766
|
+
// 3. Fetch target persona's current workflow
|
|
767
|
+
const persona = await this.getPersonaById(personaId);
|
|
768
|
+
if (!persona) {
|
|
769
|
+
throw new EmaApiError({
|
|
770
|
+
statusCode: 404,
|
|
771
|
+
message: `AI Employee not found: ${personaId}`,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const workflowDef = persona.workflow_def;
|
|
775
|
+
// 4. Chat with Autobuilder
|
|
776
|
+
const response = await this.chatWithAutobuilder(conversationId, instruction, workflowDef);
|
|
777
|
+
return {
|
|
778
|
+
persona_id: personaId,
|
|
779
|
+
persona_name: persona.name ?? "Unknown",
|
|
780
|
+
autobuilder_id: autobuilderId,
|
|
781
|
+
conversation_id: conversationId,
|
|
782
|
+
instruction,
|
|
783
|
+
response: response.response,
|
|
784
|
+
updated_workflow: response.workflow_def,
|
|
785
|
+
has_changes: !!response.workflow_def,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
}
|