alexa-ai 2.1.1
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/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const StreamParser = require('./StreamParser');
|
|
4
|
+
const { STANDARD_APIS, TASK_TYPES } = require('./Endpoints');
|
|
5
|
+
const { DeepAIError, QuotaExceededError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DeepAIClient
|
|
9
|
+
* ------------
|
|
10
|
+
* Dependency-free transport for the **whole** DeepAI surface, not just the
|
|
11
|
+
* generative endpoint. Every request shape below was taken from the live
|
|
12
|
+
* deepai.org client source.
|
|
13
|
+
*
|
|
14
|
+
* Chat
|
|
15
|
+
* POST /hacking_is_a_serious_crime multipart/form-data, header `api-key`
|
|
16
|
+
* chat_style, chatHistory, model, session_uuid, sensitivity_request_id,
|
|
17
|
+
* tool_activity_support, thinking_image_tool_support, enabled_tools,
|
|
18
|
+
* attachment_uuids, memory_enabled, web_access_enabled, sandbox_enabled,
|
|
19
|
+
* concierge_enabled, thinking_support, hacker_is_stinky
|
|
20
|
+
* -> streamed UTF-8 text with embedded packets (see StreamParser), or
|
|
21
|
+
* `{"task_id": "..."}` when thinking_support is on, or
|
|
22
|
+
* `{"status": "..."}` on refusal.
|
|
23
|
+
* GET /check_chat_task_status?type=&task_id=
|
|
24
|
+
* GET /check-sensitivity?request_id=
|
|
25
|
+
*
|
|
26
|
+
* Attachments
|
|
27
|
+
* POST /chat_attachments/upload file -> { success, attachment:{uuid,…} }
|
|
28
|
+
* GET /chat_attachments/get?uuid= extraction_status: pending|complete|skipped|failed
|
|
29
|
+
*
|
|
30
|
+
* Sessions /save_chat_session /get_chat_session /rename_chat_session
|
|
31
|
+
* /delete_chat_session /delete_all_chat_history
|
|
32
|
+
* Settings /chat_memory /chat_sandbox /chat_concierge
|
|
33
|
+
* Moderation /report_character
|
|
34
|
+
* Classic public API /api/text2img, /api/image-editor, /api/torch-srgan, …
|
|
35
|
+
*/
|
|
36
|
+
class DeepAIClient {
|
|
37
|
+
/** @param {import('./Config')} config */
|
|
38
|
+
constructor(config) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
this.log = config.logger;
|
|
41
|
+
|
|
42
|
+
this._keys = [...config.keys];
|
|
43
|
+
this._keyIndex = 0;
|
|
44
|
+
this.sessionUuid = DeepAIClient.uuid();
|
|
45
|
+
|
|
46
|
+
if (typeof fetch !== 'function') {
|
|
47
|
+
throw new DeepAIError(
|
|
48
|
+
'Global fetch() is unavailable. AlexaAI requires Node.js 18+ (or install undici).',
|
|
49
|
+
{ code: 'FETCH_UNAVAILABLE' }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =====================================================================
|
|
55
|
+
// Keys
|
|
56
|
+
// =====================================================================
|
|
57
|
+
|
|
58
|
+
/** The api-key used for the next request. */
|
|
59
|
+
get apiKey() {
|
|
60
|
+
return this._keys[this._keyIndex] || this.config.key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Move to the next configured key (or mint an anonymous one when
|
|
65
|
+
* `autoKeyRotation` is enabled). Returns false when nothing is left.
|
|
66
|
+
*/
|
|
67
|
+
rotateKey() {
|
|
68
|
+
if (this._keyIndex + 1 < this._keys.length) {
|
|
69
|
+
this._keyIndex++;
|
|
70
|
+
if (this.config.debug) this.log.warn?.('[AlexaAI] Rotating to the next DeepAI key');
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (this.config.autoKeyRotation) {
|
|
74
|
+
const fresh = DeepAIClient.generateTryItKey();
|
|
75
|
+
this._keys.push(fresh);
|
|
76
|
+
this._keyIndex = this._keys.length - 1;
|
|
77
|
+
if (this.config.debug) this.log.warn?.('[AlexaAI] Minted a fresh anonymous DeepAI key');
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Anonymous "try it" key in the shape deepai.org generates in-browser:
|
|
85
|
+
* `tryit-<10 digits>-<32 hex>`.
|
|
86
|
+
*/
|
|
87
|
+
static generateTryItKey() {
|
|
88
|
+
const digits = Array.from({ length: 10 }, () => Math.floor(Math.random() * 10)).join('');
|
|
89
|
+
const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
90
|
+
return `tryit-${digits}-${hex}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Browser-identical headers. DeepAI rejects requests without an origin. */
|
|
94
|
+
headers(extra = {}) {
|
|
95
|
+
return {
|
|
96
|
+
'api-key': this.apiKey,
|
|
97
|
+
Origin: this.config.origin,
|
|
98
|
+
Referer: `${this.config.origin}/`,
|
|
99
|
+
'User-Agent': this.config.userAgent,
|
|
100
|
+
...extra,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// =====================================================================
|
|
105
|
+
// Chat
|
|
106
|
+
// =====================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send a chat history and return the assistant's reply.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{role:string, content:string}>} messages
|
|
112
|
+
* @param {object} [options]
|
|
113
|
+
* @param {string} [options.model]
|
|
114
|
+
* @param {string[]} [options.attachmentUuids]
|
|
115
|
+
* @param {string[]} [options.models] explicit fallback chain
|
|
116
|
+
* @param {boolean} [options.thinking]
|
|
117
|
+
* @param {boolean} [options.webAccess]
|
|
118
|
+
* @param {boolean} [options.search] force the online/search flags
|
|
119
|
+
* @param {string} [options.chatStyle]
|
|
120
|
+
* @param {string} [options.sessionUuid]
|
|
121
|
+
* @param {(chunk:string, full:string)=>void} [options.onToken] streaming callback
|
|
122
|
+
* @param {AbortSignal} [options.signal]
|
|
123
|
+
* @returns {Promise<string>} the assistant text (packets stripped)
|
|
124
|
+
*/
|
|
125
|
+
async chat(messages, options = {}) {
|
|
126
|
+
const result = await this.chatDetailed(messages, options);
|
|
127
|
+
return result.text;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Same as `chat()` but returns everything the stream carried:
|
|
132
|
+
* `{ text, payload, images, functionCall, webResults, thinking, toolActivity, model }`.
|
|
133
|
+
*/
|
|
134
|
+
async chatDetailed(messages, options = {}) {
|
|
135
|
+
const chain = DeepAIClient._modelChain(options, this.config);
|
|
136
|
+
const maxAttempts = this.config.maxRetries + 1;
|
|
137
|
+
|
|
138
|
+
let lastError;
|
|
139
|
+
for (const model of chain) {
|
|
140
|
+
let attempt = 0;
|
|
141
|
+
// A quota refusal is not a failure of the model — it is a failure
|
|
142
|
+
// of the key, so trying the next key does not consume an attempt.
|
|
143
|
+
let keySwaps = this._keys.length + (this.config.autoKeyRotation ? 2 : 0);
|
|
144
|
+
|
|
145
|
+
for (;;) {
|
|
146
|
+
attempt++;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = await this._chatOnce(messages, model, options);
|
|
149
|
+
return { ...parsed, model };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
lastError = err;
|
|
152
|
+
|
|
153
|
+
if (err instanceof QuotaExceededError) {
|
|
154
|
+
if (keySwaps-- > 0 && this.rotateKey()) {
|
|
155
|
+
attempt = 0;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
break; // every key is spent: fall through to the next model
|
|
159
|
+
}
|
|
160
|
+
if (err.retryable === false) break;
|
|
161
|
+
if (attempt >= maxAttempts) break;
|
|
162
|
+
|
|
163
|
+
const delay = this.config.retryDelay * attempt;
|
|
164
|
+
if (this.config.debug) {
|
|
165
|
+
this.log.warn?.(
|
|
166
|
+
`[AlexaAI] DeepAI ${model} attempt ${attempt}/${maxAttempts} failed (${err.message}); retrying in ${delay}ms`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
await DeepAIClient.sleep(delay);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw lastError || new DeepAIError('DeepAI request failed', { code: 'DEEPAI_ERROR' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @private one request against one model. */
|
|
177
|
+
async _chatOnce(messages, model, options) {
|
|
178
|
+
const form = this.buildChatForm(messages, model, options);
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeout);
|
|
181
|
+
const signal = DeepAIClient._linkSignals(controller, options.signal);
|
|
182
|
+
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(this.config.url('chat'), {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: form,
|
|
188
|
+
headers: this.headers(),
|
|
189
|
+
signal,
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (err.name === 'AbortError' && options.signal?.aborted) {
|
|
194
|
+
throw new DeepAIError('Chat request cancelled', { code: 'ABORTED', retryable: false });
|
|
195
|
+
}
|
|
196
|
+
if (err.name === 'AbortError') {
|
|
197
|
+
throw new DeepAIError(`DeepAI timed out after ${this.config.timeout}ms`, {
|
|
198
|
+
code: 'DEEPAI_TIMEOUT',
|
|
199
|
+
retryable: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
throw new DeepAIError(`DeepAI network error: ${err.message}`, {
|
|
203
|
+
code: 'DEEPAI_NETWORK',
|
|
204
|
+
retryable: true,
|
|
205
|
+
cause: err,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (response.status > 299) {
|
|
211
|
+
const body = await response.text();
|
|
212
|
+
throw DeepAIClient._toError(response.status, body);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Reasoning models answer with { task_id } and finish asynchronously.
|
|
216
|
+
const contentType = response.headers.get('content-type') || '';
|
|
217
|
+
if (options.thinking ?? this.config.thinkingSupport) {
|
|
218
|
+
const body = await response.text();
|
|
219
|
+
const task = DeepAIClient._safeJson(body);
|
|
220
|
+
if (task?.task_id) {
|
|
221
|
+
const finished = await this.waitForTask(task.task_id, {
|
|
222
|
+
type: TASK_TYPES.thinking,
|
|
223
|
+
signal: options.signal,
|
|
224
|
+
});
|
|
225
|
+
return StreamParser.parse(DeepAIClient._taskText(finished));
|
|
226
|
+
}
|
|
227
|
+
if (task?.status) throw DeepAIClient._toError(response.status, body);
|
|
228
|
+
return StreamParser.parse(body);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const raw = await this._readStream(response, options.onToken);
|
|
232
|
+
|
|
233
|
+
// Refusals arrive as a short JSON body even with HTTP 200.
|
|
234
|
+
const status = DeepAIClient._detectJsonStatus(raw);
|
|
235
|
+
if (status) throw DeepAIClient._toError(response.status, raw, status);
|
|
236
|
+
if (contentType.includes('application/json') && !raw.trim()) {
|
|
237
|
+
throw new DeepAIError('DeepAI returned an empty body', {
|
|
238
|
+
code: 'DEEPAI_EMPTY',
|
|
239
|
+
retryable: true,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const parsed = StreamParser.parse(raw);
|
|
244
|
+
if (!parsed.text && !parsed.payload) {
|
|
245
|
+
throw new DeepAIError('DeepAI returned an empty reply', {
|
|
246
|
+
code: 'DEEPAI_EMPTY',
|
|
247
|
+
retryable: true,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
parsed.raw = raw;
|
|
251
|
+
return parsed;
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Exactly the form the browser posts. Exposed so the host bot (and tests)
|
|
259
|
+
* can inspect or extend it.
|
|
260
|
+
* @returns {FormData}
|
|
261
|
+
*/
|
|
262
|
+
buildChatForm(messages, model, options = {}) {
|
|
263
|
+
const cfg = this.config;
|
|
264
|
+
const form = new FormData();
|
|
265
|
+
|
|
266
|
+
form.append('chat_style', options.chatStyle || cfg.chatStyle);
|
|
267
|
+
form.append('chatHistory', JSON.stringify(messages));
|
|
268
|
+
form.append('model', model || cfg.model);
|
|
269
|
+
form.append('hacker_is_stinky', 'very_stinky');
|
|
270
|
+
|
|
271
|
+
if (cfg.sendSessionUuid) form.append('session_uuid', options.sessionUuid || this.sessionUuid);
|
|
272
|
+
if (options.sensitivityRequestId) form.append('sensitivity_request_id', options.sensitivityRequestId);
|
|
273
|
+
if (cfg.toolActivitySupport) form.append('tool_activity_support', '1');
|
|
274
|
+
if (cfg.thinkingImageToolSupport) form.append('thinking_image_tool_support', '1');
|
|
275
|
+
if (options.thinking ?? cfg.thinkingSupport) form.append('thinking_support', '1');
|
|
276
|
+
|
|
277
|
+
const memoryEnabled = options.serverMemory ?? cfg.serverMemory;
|
|
278
|
+
if (memoryEnabled !== undefined) form.append('memory_enabled', memoryEnabled ? 'true' : 'false');
|
|
279
|
+
const webAccess = options.webAccess ?? cfg.webAccess;
|
|
280
|
+
if (webAccess !== undefined) form.append('web_access_enabled', webAccess ? 'true' : 'false');
|
|
281
|
+
if (options.sandbox ?? cfg.sandbox) {
|
|
282
|
+
form.append('sandbox_enabled', 'true');
|
|
283
|
+
form.append('sandbox_turn_id', options.sandboxTurnId || DeepAIClient.uuid());
|
|
284
|
+
}
|
|
285
|
+
if (options.concierge ?? cfg.concierge) form.append('concierge_enabled', 'true');
|
|
286
|
+
|
|
287
|
+
if (cfg.enabledTools.length) form.append('enabled_tools', JSON.stringify(cfg.enabledTools));
|
|
288
|
+
|
|
289
|
+
if (options.summary) form.append('summary', 'summary');
|
|
290
|
+
if (options.search) {
|
|
291
|
+
form.append('online', 'online');
|
|
292
|
+
form.append('search', 'search');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Attachments ride as a TOP-LEVEL field. Putting them inside a message
|
|
296
|
+
// object makes DeepAI downgrade the request to a text-only model.
|
|
297
|
+
const uuids = Array.isArray(options.attachmentUuids) ? options.attachmentUuids.filter(Boolean) : [];
|
|
298
|
+
if (uuids.length) form.append('attachment_uuids', JSON.stringify(uuids.map(String)));
|
|
299
|
+
|
|
300
|
+
for (const [field, value] of Object.entries(options.extraFields || {})) {
|
|
301
|
+
form.append(field, typeof value === 'string' ? value : JSON.stringify(value));
|
|
302
|
+
}
|
|
303
|
+
return form;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @private Read the streamed body, feeding `onToken` as text arrives. */
|
|
307
|
+
async _readStream(response, onToken) {
|
|
308
|
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
309
|
+
return response.text();
|
|
310
|
+
}
|
|
311
|
+
const reader = response.body.getReader();
|
|
312
|
+
const decoder = new TextDecoder('utf-8');
|
|
313
|
+
let full = '';
|
|
314
|
+
let emitted = '';
|
|
315
|
+
|
|
316
|
+
for (;;) {
|
|
317
|
+
const { value, done } = await reader.read();
|
|
318
|
+
if (done) break;
|
|
319
|
+
full += decoder.decode(value, { stream: true });
|
|
320
|
+
if (typeof onToken === 'function') {
|
|
321
|
+
// Only hand the caller clean, packet-free prose.
|
|
322
|
+
const visible = StreamParser.parse(full).text;
|
|
323
|
+
if (visible.length > emitted.length) {
|
|
324
|
+
const delta = visible.slice(emitted.length);
|
|
325
|
+
emitted = visible;
|
|
326
|
+
try {
|
|
327
|
+
onToken(delta, visible);
|
|
328
|
+
} catch {
|
|
329
|
+
/* a broken consumer must not kill the stream */
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
full += decoder.decode();
|
|
335
|
+
return full;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// =====================================================================
|
|
339
|
+
// Background tasks (/check_chat_task_status)
|
|
340
|
+
// =====================================================================
|
|
341
|
+
|
|
342
|
+
/** One poll of a background task. */
|
|
343
|
+
async taskStatus(taskId, type = TASK_TYPES.thinking) {
|
|
344
|
+
return this._json(this.config.url('taskStatus', { type, task_id: taskId }), { method: 'GET' });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Poll until a task completes, fails, or `taskPollTimeout` elapses. */
|
|
348
|
+
async waitForTask(taskId, { type = TASK_TYPES.thinking, signal = null } = {}) {
|
|
349
|
+
const deadline = Date.now() + this.config.taskPollTimeout;
|
|
350
|
+
let last = null;
|
|
351
|
+
while (Date.now() < deadline) {
|
|
352
|
+
if (signal?.aborted) throw new DeepAIError('Task polling cancelled', { code: 'ABORTED', retryable: false });
|
|
353
|
+
try {
|
|
354
|
+
last = await this.taskStatus(taskId, type);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (err instanceof QuotaExceededError) throw err;
|
|
357
|
+
last = null;
|
|
358
|
+
}
|
|
359
|
+
const status = String(last?.status || '').toUpperCase();
|
|
360
|
+
if (status === 'COMPLETED' || status === 'COMPLETE' || status === 'SUCCESS') return last;
|
|
361
|
+
if (status === 'FAILED' || status === 'ERROR') {
|
|
362
|
+
throw new DeepAIError(`DeepAI task failed: ${last?.error || status}`, {
|
|
363
|
+
code: 'DEEPAI_TASK_FAILED',
|
|
364
|
+
retryable: false,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
await DeepAIClient.sleep(this.config.taskPollInterval);
|
|
368
|
+
}
|
|
369
|
+
throw new DeepAIError('DeepAI task timed out', { code: 'DEEPAI_TASK_TIMEOUT', retryable: true });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Sensitivity score for a chat turn (`sensitivity_request_id`). */
|
|
373
|
+
async checkSensitivity(requestId) {
|
|
374
|
+
try {
|
|
375
|
+
const data = await this._json(this.config.url('sensitivity', { request_id: requestId }), {
|
|
376
|
+
method: 'GET',
|
|
377
|
+
});
|
|
378
|
+
return typeof data?.score === 'number' ? data.score : null;
|
|
379
|
+
} catch {
|
|
380
|
+
return null; // never let telemetry break a reply
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// =====================================================================
|
|
385
|
+
// Attachments
|
|
386
|
+
// =====================================================================
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Upload a file so it can be referenced by `attachment_uuids`.
|
|
390
|
+
* @param {Buffer|Uint8Array} buffer
|
|
391
|
+
* @param {string} [filename]
|
|
392
|
+
* @param {string} [mimetype]
|
|
393
|
+
* @returns {Promise<object>} attachment row
|
|
394
|
+
*/
|
|
395
|
+
async uploadAttachment(buffer, filename = 'image.jpg', mimetype = 'image/jpeg') {
|
|
396
|
+
const form = new FormData();
|
|
397
|
+
form.append('file', new Blob([buffer], { type: mimetype }), filename);
|
|
398
|
+
|
|
399
|
+
const data = await this._json(this.config.url('attachmentUpload'), {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
body: form,
|
|
402
|
+
errorCode: 'UPLOAD_FAILED',
|
|
403
|
+
});
|
|
404
|
+
if (!data.success || !data.attachment) {
|
|
405
|
+
throw new DeepAIError(data.error || 'Attachment upload failed', {
|
|
406
|
+
code: 'UPLOAD_FAILED',
|
|
407
|
+
body: data,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return data.attachment;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Poll an attachment until server-side extraction finishes.
|
|
415
|
+
* Images normally return `skipped` (vision is a paid feature); documents
|
|
416
|
+
* return `complete` and their text IS injected into the model context.
|
|
417
|
+
* @param {string} uuid
|
|
418
|
+
* @param {number} [attempts=3]
|
|
419
|
+
* @returns {Promise<object|null>}
|
|
420
|
+
*/
|
|
421
|
+
async getAttachment(uuid, attempts = 3) {
|
|
422
|
+
for (let i = 0; i < attempts; i++) {
|
|
423
|
+
try {
|
|
424
|
+
const data = await this._json(this.config.url('attachmentGet', { uuid }), { method: 'GET' });
|
|
425
|
+
const status = data?.attachment?.extraction_status;
|
|
426
|
+
if (data?.success && status !== 'pending' && status !== 'processing') return data.attachment;
|
|
427
|
+
} catch {
|
|
428
|
+
/* retry */
|
|
429
|
+
}
|
|
430
|
+
await DeepAIClient.sleep(1200);
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// =====================================================================
|
|
436
|
+
// Server-side chat sessions
|
|
437
|
+
// =====================================================================
|
|
438
|
+
|
|
439
|
+
/** Persist a transcript on DeepAI (`/save_chat_session`). */
|
|
440
|
+
async saveSession({ uuid = this.sessionUuid, title = '', messages = [], model, chatStyle } = {}) {
|
|
441
|
+
const form = new FormData();
|
|
442
|
+
form.append('uuid', uuid);
|
|
443
|
+
form.append('title', title || '');
|
|
444
|
+
form.append('chat_style', chatStyle || this.config.chatStyle);
|
|
445
|
+
form.append('chat_model', model || this.config.model);
|
|
446
|
+
form.append('messages', JSON.stringify(messages));
|
|
447
|
+
return this._json(this.config.url('saveSession'), { method: 'POST', body: form });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Load a transcript (`/get_chat_session`). */
|
|
451
|
+
async getSession(uuid) {
|
|
452
|
+
return this._json(this.config.url('getSession', { uuid }), { method: 'GET' });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Rename a transcript (`/rename_chat_session`). */
|
|
456
|
+
async renameSession(uuid, title) {
|
|
457
|
+
const form = new FormData();
|
|
458
|
+
form.append('uuid', uuid);
|
|
459
|
+
form.append('title', String(title ?? ''));
|
|
460
|
+
return this._json(this.config.url('renameSession'), { method: 'POST', body: form });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Delete one transcript (`/delete_chat_session`). */
|
|
464
|
+
async deleteSession(uuid) {
|
|
465
|
+
const form = new FormData();
|
|
466
|
+
form.append('uuid', uuid);
|
|
467
|
+
return this._json(this.config.url('deleteSession'), { method: 'POST', body: form });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Delete every transcript (`/delete_all_chat_history`). */
|
|
471
|
+
async deleteAllSessions(knownUuids = []) {
|
|
472
|
+
const form = new FormData();
|
|
473
|
+
form.append('my_known_uuids', JSON.stringify(knownUuids));
|
|
474
|
+
return this._json(this.config.url('deleteAllSessions'), { method: 'POST', body: form });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// =====================================================================
|
|
478
|
+
// Account-level settings
|
|
479
|
+
// =====================================================================
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* DeepAI's own long-term memory profile (`/chat_memory`).
|
|
483
|
+
* `action` is omitted to read, or one of the site's actions to write
|
|
484
|
+
* (e.g. 'refresh', 'set_enabled', 'set_profile').
|
|
485
|
+
*/
|
|
486
|
+
async chatMemory(action = null, fields = {}) {
|
|
487
|
+
return this._settings('memory', action, fields);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Agent-mode toggle (`/chat_sandbox`). */
|
|
491
|
+
async chatSandbox(enabled) {
|
|
492
|
+
return this._settings('sandbox', enabled === undefined ? null : 'set_enabled', {
|
|
493
|
+
enabled: enabled ? 'true' : 'false',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Background-task toggle (`/chat_concierge`). */
|
|
498
|
+
async chatConcierge(enabled) {
|
|
499
|
+
return this._settings('concierge', enabled === undefined ? null : 'set_enabled', {
|
|
500
|
+
enabled: enabled ? 'true' : 'false',
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Abuse report for a character chat (`/report_character`). */
|
|
505
|
+
async reportCharacter({ reason, characterUrl = null, history = [] }) {
|
|
506
|
+
const form = new FormData();
|
|
507
|
+
form.append('reason', String(reason ?? ''));
|
|
508
|
+
if (characterUrl) form.append('character_url', characterUrl);
|
|
509
|
+
form.append('chat_history', JSON.stringify(history));
|
|
510
|
+
return this._json(this.config.url('reportCharacter'), { method: 'POST', body: form });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @private GET-to-read / POST-to-write settings endpoints. */
|
|
514
|
+
async _settings(endpoint, action, fields) {
|
|
515
|
+
const url = this.config.url(endpoint);
|
|
516
|
+
if (!action) return this._json(url, { method: 'GET' });
|
|
517
|
+
const form = new FormData();
|
|
518
|
+
form.append('action', action);
|
|
519
|
+
for (const [k, v] of Object.entries(fields || {})) form.append(k, String(v));
|
|
520
|
+
return this._json(url, { method: 'POST', body: form });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// =====================================================================
|
|
524
|
+
// Classic public API (/api/<name>)
|
|
525
|
+
// =====================================================================
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Call any endpoint of DeepAI's public API family.
|
|
529
|
+
*
|
|
530
|
+
* runApi('text2img', { text: 'a cat' })
|
|
531
|
+
* runApi('torch-srgan', { image: buffer })
|
|
532
|
+
* runApi('nsfw-detector', { image: 'https://…' })
|
|
533
|
+
*
|
|
534
|
+
* Buffers/Uint8Arrays are uploaded as files, everything else as fields.
|
|
535
|
+
* @returns {Promise<object>} e.g. `{ id, output_url }`
|
|
536
|
+
*/
|
|
537
|
+
async runApi(name, fields = {}, options = {}) {
|
|
538
|
+
const form = new FormData();
|
|
539
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
540
|
+
if (value == null) continue;
|
|
541
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
542
|
+
const mimetype = options.mimetype || DeepAIClient._sniffMime(value) || 'application/octet-stream';
|
|
543
|
+
form.append(key, new Blob([value], { type: mimetype }), options.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
544
|
+
} else if (typeof value === 'object' && (value.buffer || value.url)) {
|
|
545
|
+
if (value.url && !value.buffer) {
|
|
546
|
+
form.append(key, String(value.url));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const bytes = Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer);
|
|
550
|
+
const mimetype = value.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
551
|
+
form.append(key, new Blob([bytes], { type: mimetype }), value.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
552
|
+
} else if (typeof value === 'object') {
|
|
553
|
+
form.append(key, JSON.stringify(value));
|
|
554
|
+
} else {
|
|
555
|
+
form.append(key, String(value));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
|
|
559
|
+
const data = await this._json(url, { method: 'POST', body: form, signal: options.signal });
|
|
560
|
+
// The classic API reports failures as `{ err: "..." }` or `{ status: "..." }` with HTTP 200.
|
|
561
|
+
if (data?.err) {
|
|
562
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
|
|
563
|
+
}
|
|
564
|
+
if (typeof data?.status === 'string' && !data.output_url && !data.output && !data.id) {
|
|
565
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
|
|
566
|
+
}
|
|
567
|
+
return data;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Text-to-image (`/api/text2img`). Returns `{ id, output_url }`. */
|
|
571
|
+
async text2img(text, extra = {}, options = {}) {
|
|
572
|
+
return this.runApi(this.config.imageModel || STANDARD_APIS.text2img, { text, ...extra }, options);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Prompt-driven image edit (`/api/image-editor`). */
|
|
576
|
+
async editImage(image, text, extra = {}) {
|
|
577
|
+
return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 4x upscale (`/api/torch-srgan`). */
|
|
581
|
+
async upscaleImage(image, extra = {}) {
|
|
582
|
+
return this.runApi(STANDARD_APIS.superResolution, { image, ...extra });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Colourise a black-and-white photo (`/api/colorizer`). */
|
|
586
|
+
async colorizeImage(image, extra = {}) {
|
|
587
|
+
return this.runApi(STANDARD_APIS.colorizer, { image, ...extra });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** NSFW score (`/api/nsfw-detector`). */
|
|
591
|
+
async detectNsfw(image, extra = {}) {
|
|
592
|
+
return this.runApi(STANDARD_APIS.nsfwDetector, { image, ...extra });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Abstractive summary (`/api/summarization`). */
|
|
596
|
+
async summarize(text, extra = {}) {
|
|
597
|
+
return this.runApi(STANDARD_APIS.summarization, { text, ...extra });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Sentiment labels (`/api/sentiment-analysis`). */
|
|
601
|
+
async sentiment(text, extra = {}) {
|
|
602
|
+
return this.runApi(STANDARD_APIS.sentiment, { text, ...extra });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// =====================================================================
|
|
606
|
+
// Internals
|
|
607
|
+
// =====================================================================
|
|
608
|
+
|
|
609
|
+
/** @private JSON request with uniform timeout + error handling. */
|
|
610
|
+
async _json(url, { method = 'GET', body = null, headers = {}, signal = null, errorCode = null } = {}) {
|
|
611
|
+
const controller = new AbortController();
|
|
612
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeout);
|
|
613
|
+
const linked = DeepAIClient._linkSignals(controller, signal);
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const response = await fetch(url, {
|
|
617
|
+
method,
|
|
618
|
+
body,
|
|
619
|
+
headers: this.headers(headers),
|
|
620
|
+
signal: linked,
|
|
621
|
+
});
|
|
622
|
+
const text = await response.text();
|
|
623
|
+
const data = DeepAIClient._safeJson(text);
|
|
624
|
+
|
|
625
|
+
if (response.status > 299) {
|
|
626
|
+
throw DeepAIClient._toError(response.status, text, data?.status || data?.error);
|
|
627
|
+
}
|
|
628
|
+
if (data === null) {
|
|
629
|
+
throw new DeepAIError(`DeepAI returned non-JSON from ${url}: ${text.slice(0, 200)}`, {
|
|
630
|
+
code: errorCode || 'BAD_RESPONSE',
|
|
631
|
+
status: response.status,
|
|
632
|
+
retryable: true,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (typeof data.status === 'string' && DeepAIClient._isRefusal(data.status)) {
|
|
636
|
+
throw DeepAIClient._toError(response.status, text, data.status);
|
|
637
|
+
}
|
|
638
|
+
return data;
|
|
639
|
+
} catch (err) {
|
|
640
|
+
if (err instanceof DeepAIError) throw err;
|
|
641
|
+
if (err.name === 'AbortError') {
|
|
642
|
+
throw new DeepAIError(`DeepAI request to ${url} timed out`, {
|
|
643
|
+
code: 'DEEPAI_TIMEOUT',
|
|
644
|
+
retryable: true,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
throw new DeepAIError(`DeepAI request to ${url} failed: ${err.message}`, {
|
|
648
|
+
code: errorCode || 'DEEPAI_NETWORK',
|
|
649
|
+
retryable: true,
|
|
650
|
+
cause: err,
|
|
651
|
+
});
|
|
652
|
+
} finally {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** @private models to try, in order. */
|
|
658
|
+
static _modelChain(options, config) {
|
|
659
|
+
if (Array.isArray(options.models) && options.models.length) return options.models;
|
|
660
|
+
const primary = options.model || config.model;
|
|
661
|
+
return [primary, ...config.fallbackModels.filter((m) => m !== primary)];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** @private the assistant text carried by a finished thinking task. */
|
|
665
|
+
static _taskText(task) {
|
|
666
|
+
if (!task) return '';
|
|
667
|
+
return (
|
|
668
|
+
task.result ||
|
|
669
|
+
task.response ||
|
|
670
|
+
task.output ||
|
|
671
|
+
task.text ||
|
|
672
|
+
(typeof task.data === 'string' ? task.data : '') ||
|
|
673
|
+
''
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** @private tie an external AbortSignal to our timeout controller. */
|
|
678
|
+
static _linkSignals(controller, external) {
|
|
679
|
+
if (external) {
|
|
680
|
+
if (external.aborted) controller.abort();
|
|
681
|
+
else external.addEventListener?.('abort', () => controller.abort(), { once: true });
|
|
682
|
+
}
|
|
683
|
+
return controller.signal;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
static _safeJson(text) {
|
|
687
|
+
try {
|
|
688
|
+
const parsed = JSON.parse(text);
|
|
689
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
690
|
+
} catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* DeepAI signals refusals with a small JSON body `{"status": "..."}`.
|
|
697
|
+
* A normal reply is plain prose, so only treat *short* JSON as a status.
|
|
698
|
+
* @private
|
|
699
|
+
*/
|
|
700
|
+
static _detectJsonStatus(text) {
|
|
701
|
+
const trimmed = String(text ?? '').trim();
|
|
702
|
+
if (!trimmed.startsWith('{') || trimmed.length > 600) return null;
|
|
703
|
+
try {
|
|
704
|
+
const parsed = JSON.parse(trimmed);
|
|
705
|
+
if (parsed && typeof parsed.status === 'string') return parsed.status;
|
|
706
|
+
if (parsed && typeof parsed.error === 'string') return parsed.error;
|
|
707
|
+
} catch {
|
|
708
|
+
/* genuine prose that merely starts with '{' */
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
static _isRefusal(status) {
|
|
714
|
+
return /exceeded|paid|credits|api-key|api key|login|not allowed|forbidden|unauthori[sz]ed/i.test(status);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** @private magic-number sniff so uploads carry a real content type. */
|
|
718
|
+
static _sniffMime(b) {
|
|
719
|
+
if (!b || b.length < 4) return null;
|
|
720
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
|
|
721
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
|
722
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
|
|
723
|
+
if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) return 'application/pdf';
|
|
724
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
|
|
725
|
+
return 'image/webp';
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** @private file extension for a mimetype. */
|
|
731
|
+
static _ext(mimetype) {
|
|
732
|
+
const map = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'application/pdf': 'pdf', 'text/plain': 'txt' };
|
|
733
|
+
return map[mimetype] || 'bin';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** @private */
|
|
737
|
+
static _toError(status, body, statusMessage) {
|
|
738
|
+
const msg = statusMessage || DeepAIClient._detectJsonStatus(body) || `HTTP ${status}`;
|
|
739
|
+
const lowered = String(msg).toLowerCase();
|
|
740
|
+
|
|
741
|
+
const quotaHints = [
|
|
742
|
+
'quota exceeded',
|
|
743
|
+
'try it exceeded',
|
|
744
|
+
'try-it quota exceeded',
|
|
745
|
+
'only paid accounts',
|
|
746
|
+
'paid users',
|
|
747
|
+
'out of credits',
|
|
748
|
+
'invalid authentication',
|
|
749
|
+
'api key',
|
|
750
|
+
'api-key',
|
|
751
|
+
'please login',
|
|
752
|
+
];
|
|
753
|
+
if (quotaHints.some((h) => lowered.includes(h))) {
|
|
754
|
+
return new QuotaExceededError(`DeepAI refused the request: ${msg}`, { status, body });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 5xx and 429 are transient.
|
|
758
|
+
const retryable = status >= 500 || status === 429 || status === 408;
|
|
759
|
+
return new DeepAIError(`DeepAI request failed: ${msg}`, {
|
|
760
|
+
status,
|
|
761
|
+
body: typeof body === 'string' ? body.slice(0, 500) : body,
|
|
762
|
+
retryable,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** RFC4122 v4, without pulling in a dependency. */
|
|
767
|
+
static uuid() {
|
|
768
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
769
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
770
|
+
const r = (Math.random() * 16) | 0;
|
|
771
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
static sleep(ms) {
|
|
776
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** @deprecated kept for older call sites */
|
|
780
|
+
static _uuid() {
|
|
781
|
+
return DeepAIClient.uuid();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
static _sleep(ms) {
|
|
785
|
+
return DeepAIClient.sleep(ms);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
module.exports = DeepAIClient;
|