zyn-ai 1.3.4 → 1.3.5
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 +7 -11
- package/package.json +1 -1
- package/src/cli/commands.js +77 -7
- package/src/config.js +28 -21
- package/src/core/agent.js +52 -11
- package/src/core/prompts.js +48 -4
- package/src/i18n.js +2 -2
- package/src/providers/catalog.js +27 -43
- package/src/providers/gemini/index.js +338 -0
- package/src/providers/scraperClient.js +3 -6
- package/src/tools/index.js +230 -0
- package/src/tui/app.mjs +17 -1
- package/src/utils/gmailAuth.js +427 -0
- package/src/utils/sessionStorage.js +16 -9
- package/src/web/public/index.html +3 -1
- package/src/web/server.js +10 -3
- package/src/web/webAgent.js +5 -3
- package/src/providers/ollama/index.js +0 -78
- package/src/providers/openaiCompatible/index.js +0 -97
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
const { Buffer } = require('buffer');
|
|
2
|
+
const { REQUEST_TIMEOUT_MS } = require('../../config');
|
|
3
|
+
const { parseAgentResponse } = require('../../core/prompts');
|
|
4
|
+
|
|
5
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36';
|
|
6
|
+
const GEMINI_BASE = 'https://gemini.google.com';
|
|
7
|
+
const ANON_COOKIE_URL = `${GEMINI_BASE}/_/BardChatUi/data/batchexecute?rpcids=maGuAc&source-path=%2F&hl=en-US&rt=c`;
|
|
8
|
+
const STREAM_URL = `${GEMINI_BASE}/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?hl=en-US&rt=c`;
|
|
9
|
+
|
|
10
|
+
function btoa2(str) {
|
|
11
|
+
return Buffer.from(str, 'utf8').toString('base64');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function atob2(b64) {
|
|
15
|
+
return Buffer.from(b64, 'base64').toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sleep(ms, signal) {
|
|
19
|
+
if (!ms || ms <= 0) return Promise.resolve();
|
|
20
|
+
if (signal?.aborted) return Promise.reject(new Error('aborted'));
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const timeout = setTimeout(() => {
|
|
23
|
+
cleanup();
|
|
24
|
+
resolve();
|
|
25
|
+
}, ms);
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
clearTimeout(timeout);
|
|
28
|
+
cleanup();
|
|
29
|
+
reject(new Error('aborted'));
|
|
30
|
+
};
|
|
31
|
+
function cleanup() {
|
|
32
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
33
|
+
}
|
|
34
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createTimeoutSignal(signal, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
41
|
+
const onExternalAbort = () => controller.abort();
|
|
42
|
+
|
|
43
|
+
if (signal) {
|
|
44
|
+
if (signal.aborted) controller.abort();
|
|
45
|
+
else signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
cleanup() {
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
if (signal) signal.removeEventListener('abort', onExternalAbort);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
58
|
+
const timeoutSignal = createTimeoutSignal(options.signal, options.timeoutMs || REQUEST_TIMEOUT_MS);
|
|
59
|
+
try {
|
|
60
|
+
return await fetch(url, {
|
|
61
|
+
...options,
|
|
62
|
+
signal: timeoutSignal.signal,
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
timeoutSignal.cleanup();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walkDeep(node, visit, depth = 0, maxDepth = 7) {
|
|
70
|
+
if (depth > maxDepth) return;
|
|
71
|
+
if (visit(node, depth) === false) return;
|
|
72
|
+
if (Array.isArray(node)) {
|
|
73
|
+
for (const x of node) walkDeep(x, visit, depth + 1, maxDepth);
|
|
74
|
+
} else if (node && typeof node === 'object') {
|
|
75
|
+
for (const k of Object.keys(node)) walkDeep(node[k], visit, depth + 1, maxDepth);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function getAnonCookie(signal) {
|
|
80
|
+
const res = await fetchWithTimeout(ANON_COOKIE_URL, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
redirect: 'manual',
|
|
83
|
+
headers: {
|
|
84
|
+
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
85
|
+
'user-agent': UA,
|
|
86
|
+
},
|
|
87
|
+
body: 'f.req=%5B%5B%5B%22maGuAc%22%2C%22%5B0%5D%22%2Cnull%2C%22generic%22%5D%5D%5D&',
|
|
88
|
+
signal,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const setCookie = res.headers.get('set-cookie');
|
|
92
|
+
if (!setCookie) throw new Error('Gemini no devolvió cookies');
|
|
93
|
+
return setCookie.split(';')[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function getXsrfToken(cookieHeader, signal) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetchWithTimeout(`${GEMINI_BASE}/app`, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
headers: {
|
|
101
|
+
'user-agent': UA,
|
|
102
|
+
cookie: cookieHeader,
|
|
103
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
104
|
+
},
|
|
105
|
+
signal,
|
|
106
|
+
});
|
|
107
|
+
const html = await res.text();
|
|
108
|
+
const m1 = html.match(/"SNlM0e":"([^"]+)"/);
|
|
109
|
+
if (m1?.[1]) return m1[1];
|
|
110
|
+
const m2 = html.match(/"at":"([^"]+)"/);
|
|
111
|
+
if (m2?.[1]) return m2[1];
|
|
112
|
+
} catch {}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
function extractLooseFinalContent(text) {
|
|
118
|
+
const raw = String(text || '').trim();
|
|
119
|
+
if (!/(?:["'])type(?:["'])\s*:\s*(?:["'])final(?:["'])/i.test(raw)) return null;
|
|
120
|
+
|
|
121
|
+
const contentMatch = raw.match(/(?:["'])content(?:["'])\s*:\s*(["'])/i);
|
|
122
|
+
if (!contentMatch) return null;
|
|
123
|
+
|
|
124
|
+
const quote = contentMatch[1];
|
|
125
|
+
const start = contentMatch.index + contentMatch[0].length;
|
|
126
|
+
let escaped = false;
|
|
127
|
+
|
|
128
|
+
for (let i = start; i < raw.length; i += 1) {
|
|
129
|
+
const ch = raw[i];
|
|
130
|
+
if (escaped) { escaped = false; continue; }
|
|
131
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
132
|
+
if (ch !== quote) continue;
|
|
133
|
+
|
|
134
|
+
const tail = raw.slice(i + 1).trim();
|
|
135
|
+
if (!tail || /^}\s*$/.test(tail) || /^,\s*["']\w+["']\s*:/.test(tail)) {
|
|
136
|
+
return raw.slice(start, i);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return raw.slice(start).replace(/"?\s*}\s*$/, '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unescapeLooseJsonString(text) {
|
|
144
|
+
return String(text || '')
|
|
145
|
+
.replace(/\\n/g, '\n')
|
|
146
|
+
.replace(/\\t/g, '\t')
|
|
147
|
+
.replace(/\\r/g, '\r')
|
|
148
|
+
.replace(/\\"/g, '"')
|
|
149
|
+
.replace(/\\\\/g, '\\')
|
|
150
|
+
.trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cleanGeminiResponseText(text) {
|
|
154
|
+
const raw = String(text || '').trim();
|
|
155
|
+
if (!raw) return '';
|
|
156
|
+
|
|
157
|
+
const unfenced = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
|
|
158
|
+
const parsed = parseAgentResponse(unfenced);
|
|
159
|
+
if (parsed?.type === 'final' && typeof parsed.content === 'string' && parsed.content.trim()) {
|
|
160
|
+
return parsed.content.trim();
|
|
161
|
+
}
|
|
162
|
+
if (parsed?.type === 'tool') return unfenced;
|
|
163
|
+
|
|
164
|
+
const looseFinal = extractLooseFinalContent(unfenced);
|
|
165
|
+
if (looseFinal !== null) return unescapeLooseJsonString(looseFinal);
|
|
166
|
+
|
|
167
|
+
return unfenced;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isLikelyText(value) {
|
|
171
|
+
if (typeof value !== 'string') return false;
|
|
172
|
+
const text = value.trim();
|
|
173
|
+
if (!text) return false;
|
|
174
|
+
if (text.length < 2) return false;
|
|
175
|
+
if (/^https?:\/\//i.test(text)) return false;
|
|
176
|
+
if (/^\/\/www\./i.test(text)) return false;
|
|
177
|
+
if (/maps\/vt\/data/i.test(text)) return false;
|
|
178
|
+
if (/^c_[0-9a-f]{6,}$/i.test(text)) return false;
|
|
179
|
+
if (/^[A-Za-z0-9_\-+/=]{16,}$/.test(text) && !/\s/.test(text)) return false;
|
|
180
|
+
if (/^\{.*\}$/.test(text) || /^\[.*\]$/.test(text)) return false;
|
|
181
|
+
return text.length >= 8 || /\s/.test(text);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function pickBestTextFromAny(parsed) {
|
|
185
|
+
const found = [];
|
|
186
|
+
walkDeep(parsed, (node) => {
|
|
187
|
+
if (typeof node !== 'string' || !isLikelyText(node)) return;
|
|
188
|
+
const cleaned = cleanGeminiResponseText(node);
|
|
189
|
+
if (cleaned) found.push(cleaned);
|
|
190
|
+
});
|
|
191
|
+
found.sort((a, b) => b.length - a.length);
|
|
192
|
+
return found[0] || '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pickFirstString(parsed, accept) {
|
|
196
|
+
let first = '';
|
|
197
|
+
walkDeep(parsed, (node) => {
|
|
198
|
+
if (first) return false;
|
|
199
|
+
if (typeof node !== 'string') return undefined;
|
|
200
|
+
const text = node.trim();
|
|
201
|
+
if (text && (!accept || accept(text))) first = text;
|
|
202
|
+
if (first) return false;
|
|
203
|
+
return undefined;
|
|
204
|
+
});
|
|
205
|
+
return first;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function findInnerPayloadString(outer) {
|
|
209
|
+
const candidates = [];
|
|
210
|
+
const add = (value) => {
|
|
211
|
+
if (typeof value !== 'string') return;
|
|
212
|
+
const text = value.trim();
|
|
213
|
+
if (!text) return;
|
|
214
|
+
candidates.push(text);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
add(outer?.[0]?.[2]);
|
|
218
|
+
add(outer?.[2]);
|
|
219
|
+
add(outer?.[0]?.[0]?.[2]);
|
|
220
|
+
walkDeep(outer, (node) => {
|
|
221
|
+
if (typeof node === 'string') {
|
|
222
|
+
const text = node.trim();
|
|
223
|
+
if ((text.startsWith('[') || text.startsWith('{')) && text.length > 20) add(text);
|
|
224
|
+
}
|
|
225
|
+
}, 0, 5);
|
|
226
|
+
|
|
227
|
+
for (const candidate of candidates) {
|
|
228
|
+
try {
|
|
229
|
+
JSON.parse(candidate);
|
|
230
|
+
return candidate;
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseStream(data) {
|
|
237
|
+
if (typeof data !== 'string' || !data.trim()) throw new Error('Respuesta vacía');
|
|
238
|
+
|
|
239
|
+
const chunks = Array.from(
|
|
240
|
+
data.matchAll(/^\d+\r?\n([\s\S]+?)\r?\n(?=\d+\r?\n|$)/gm),
|
|
241
|
+
).map(match => match[1]).reverse();
|
|
242
|
+
|
|
243
|
+
if (!chunks.length) throw new Error('Respuesta inválida');
|
|
244
|
+
|
|
245
|
+
let best = { text: '', resumeArray: null, parsed: null };
|
|
246
|
+
for (const chunk of chunks) {
|
|
247
|
+
try {
|
|
248
|
+
const outer = JSON.parse(chunk);
|
|
249
|
+
const inner = findInnerPayloadString(outer);
|
|
250
|
+
if (!inner) continue;
|
|
251
|
+
const parsed = JSON.parse(inner);
|
|
252
|
+
const text = pickBestTextFromAny(parsed);
|
|
253
|
+
const resumeArray = Array.isArray(parsed?.[1]) ? parsed[1] : null;
|
|
254
|
+
if (!best.parsed || (text && text.length > (best.text?.length || 0))) {
|
|
255
|
+
best = { text, resumeArray, parsed };
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!best.parsed) throw new Error('Error de parseo');
|
|
261
|
+
|
|
262
|
+
let cleanText = cleanGeminiResponseText(best.text).replace(/\*\*(.+?)\*\*/g, '*$1*').trim();
|
|
263
|
+
if (!cleanText) {
|
|
264
|
+
const accept = text => !/^https?:\/\/|^\/\/www\.|maps\/vt\/data/i.test(text);
|
|
265
|
+
cleanText = cleanGeminiResponseText(pickFirstString(best.parsed, accept) || pickFirstString(best.parsed)).replace(/\*\*(.+?)\*\*/g, '*$1*').trim();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { text: cleanText, resumeArray: best.resumeArray };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function geminiScraper(prompt, previousId = null, options = {}) {
|
|
272
|
+
let resumeArray = null;
|
|
273
|
+
if (previousId) {
|
|
274
|
+
try {
|
|
275
|
+
const json = JSON.parse(atob2(previousId));
|
|
276
|
+
resumeArray = json?.resumeArray || null;
|
|
277
|
+
} catch {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let lastErr = null;
|
|
281
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
282
|
+
try {
|
|
283
|
+
if (options.signal?.aborted) throw new Error('aborted');
|
|
284
|
+
const cookie = await getAnonCookie(options.signal);
|
|
285
|
+
const xsrf = await getXsrfToken(cookie, options.signal);
|
|
286
|
+
const payload = [[prompt.trim()], ['en-US'], resumeArray];
|
|
287
|
+
const fReq = [null, JSON.stringify(payload)];
|
|
288
|
+
const params = new URLSearchParams({ 'f.req': JSON.stringify(fReq) });
|
|
289
|
+
if (xsrf) params.append('at', xsrf);
|
|
290
|
+
|
|
291
|
+
const response = await fetchWithTimeout(STREAM_URL, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: {
|
|
294
|
+
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
295
|
+
'user-agent': UA,
|
|
296
|
+
'x-same-domain': '1',
|
|
297
|
+
cookie,
|
|
298
|
+
},
|
|
299
|
+
body: params,
|
|
300
|
+
signal: options.signal,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const data = await response.text();
|
|
304
|
+
if (!response.ok) throw new Error(`Gemini fallo (${response.status}): ${data.slice(0, 200)}`);
|
|
305
|
+
const parsed = parseStream(data);
|
|
306
|
+
const id = btoa2(JSON.stringify({ resumeArray: parsed.resumeArray }));
|
|
307
|
+
return { status: true, response: cleanGeminiResponseText(parsed.text), id };
|
|
308
|
+
} catch (err) {
|
|
309
|
+
lastErr = err;
|
|
310
|
+
if (options.signal?.aborted) break;
|
|
311
|
+
if (attempt < 3) await sleep(700, options.signal);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { status: false, message: lastErr?.message || 'Gemini fallo' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function gemini(messages, _modelId, onChunk = null, options = {}) {
|
|
319
|
+
const prompt = Array.isArray(messages)
|
|
320
|
+
? messages.map(message => {
|
|
321
|
+
if (message.role === 'system') return `[Sistema]\n${message.content}`;
|
|
322
|
+
if (message.role === 'assistant') return `[Asistente]\n${message.content}`;
|
|
323
|
+
return `[Usuario]\n${message.content}`;
|
|
324
|
+
}).join('\n\n')
|
|
325
|
+
: String(messages || '');
|
|
326
|
+
|
|
327
|
+
const result = await geminiScraper(prompt, options.previousId || null, options);
|
|
328
|
+
if (!result.status) throw new Error(result.message || 'Gemini fallo');
|
|
329
|
+
if (onChunk && result.response) onChunk(result.response, 'answer');
|
|
330
|
+
return { text: result.response || '', thinking: '', id: result.id };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
gemini,
|
|
335
|
+
geminiScraper,
|
|
336
|
+
cleanGeminiResponseText,
|
|
337
|
+
parseStream,
|
|
338
|
+
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const { qwen } = require('./qwen/index');
|
|
2
2
|
const { zen } = require('./zen/index');
|
|
3
|
-
const {
|
|
4
|
-
const { openaiCompatible } = require('./openaiCompatible/index');
|
|
3
|
+
const { gemini } = require('./gemini/index');
|
|
5
4
|
const { DEFAULT_MODEL_KEY, MODELS } = require('../config');
|
|
6
5
|
|
|
7
6
|
function buildPromptFromMessages(messages) {
|
|
@@ -29,10 +28,8 @@ async function runProvider(provider, messages, model, onChunk, options = {}) {
|
|
|
29
28
|
switch (provider) {
|
|
30
29
|
case 'zen':
|
|
31
30
|
return zen(messages, model.zenModel, onChunk, options);
|
|
32
|
-
case '
|
|
33
|
-
return
|
|
34
|
-
case 'openai-compatible':
|
|
35
|
-
return openaiCompatible(messages, model.openaiModel || model.model || model.label, onChunk, options);
|
|
31
|
+
case 'gemini':
|
|
32
|
+
return gemini(messages, model.geminiModel || 'gemini-flash', onChunk, options);
|
|
36
33
|
case 'qwen':
|
|
37
34
|
default: {
|
|
38
35
|
const prompt = buildPromptFromMessages(messages);
|
package/src/tools/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
upsertGitSecret,
|
|
19
19
|
} = require('../utils/secretStorage');
|
|
20
20
|
const { resolveInputPath } = require('../utils/pathUtils');
|
|
21
|
+
const { getGmailAuthStatus, gmailApiRequest } = require('../utils/gmailAuth');
|
|
21
22
|
const {
|
|
22
23
|
formatLineRange,
|
|
23
24
|
shortText,
|
|
@@ -42,6 +43,8 @@ const TOOL_DEFINITIONS = [
|
|
|
42
43
|
{ name: 'scrape_site', usage: '{ url, selectors, limit?, headers? }' },
|
|
43
44
|
{ name: 'web_search', usage: '{ query, lang?, limit? }' },
|
|
44
45
|
{ name: 'web_read', usage: '{ url }' },
|
|
46
|
+
{ name: 'upload_file', usage: '{ path, field?, name?, type? }' },
|
|
47
|
+
{ name: 'gmail', usage: '{ action, query?, maxResults?, id?, to?, subject?, body? }' },
|
|
45
48
|
{ name: 'create_canvas_image', usage: '{ width, height, background?, elements?, format?, outputPath? }' },
|
|
46
49
|
{ name: 'git', usage: '{ provider, action, method?, path?, body?, headers?, name?, repoUrl?, destination?, branch?, timeoutMs? }' },
|
|
47
50
|
];
|
|
@@ -134,6 +137,19 @@ function getToolPromptText() {
|
|
|
134
137
|
' Ideal para leer articulos, documentacion o contenido de paginas.',
|
|
135
138
|
' Ejemplo: {"type":"tool","tool":"web_read","args":{"url":"https://docs.example.com/guide"}}',
|
|
136
139
|
'',
|
|
140
|
+
'upload_file { path, field?, name?, type? }',
|
|
141
|
+
' Sube un archivo local a https://cdn.soymaycol.icu/upload por POST multipart/form-data y devuelve el link directo.',
|
|
142
|
+
' Limite estricto: maximo 5 MB. field por defecto: "file". name/type son opcionales.',
|
|
143
|
+
' Usa esta tool cuando necesites entregar un archivo como enlace directo al agente o al usuario.',
|
|
144
|
+
' Ejemplo: {"type":"tool","tool":"upload_file","args":{"path":"dist/116.zip"}}',
|
|
145
|
+
'',
|
|
146
|
+
'gmail { action, query?, maxResults?, id?, to?, subject?, body? }',
|
|
147
|
+
' Usa Gmail conectado con /gmail connect. Acciones: status, list, read, send.',
|
|
148
|
+
' list: query usa la sintaxis de busqueda de Gmail; maxResults default 10, max 20.',
|
|
149
|
+
' read: requiere id de mensaje. send: requiere to, subject y body; pide confirmacion antes de enviar.',
|
|
150
|
+
' Ejemplo listar: {"type":"tool","tool":"gmail","args":{"action":"list","query":"is:unread newer_than:7d","maxResults":5}}',
|
|
151
|
+
' Ejemplo leer: {"type":"tool","tool":"gmail","args":{"action":"read","id":"MESSAGE_ID"}}',
|
|
152
|
+
'',
|
|
137
153
|
'## Imagen profesional con Jimp',
|
|
138
154
|
'',
|
|
139
155
|
'create_canvas_image { width, height, background?, elements?, format?, outputPath? }',
|
|
@@ -256,6 +272,10 @@ function describeToolCall(call) {
|
|
|
256
272
|
const readUrl = cleanUrl(call.args.url || '');
|
|
257
273
|
return `Leyendo ${shortText(readUrl, 60)}`;
|
|
258
274
|
}
|
|
275
|
+
case 'upload_file':
|
|
276
|
+
return `Subiendo ${call.args.path}`;
|
|
277
|
+
case 'gmail':
|
|
278
|
+
return `Gmail ${call.args.action || 'status'}`;
|
|
259
279
|
case 'create_canvas_image':
|
|
260
280
|
return `Creando imagen ${call.args.width || '?'}x${call.args.height || '?'}`;
|
|
261
281
|
case 'git':
|
|
@@ -903,6 +923,210 @@ async function fetchHttpTool(args, state, paint) {
|
|
|
903
923
|
return truncateText(`Status: ${res.status}\nContent-Type: ${res.headers.get('content-type') || '-'}\n\n${text}`);
|
|
904
924
|
}
|
|
905
925
|
|
|
926
|
+
|
|
927
|
+
function guessContentType(filePath) {
|
|
928
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
929
|
+
const types = {
|
|
930
|
+
'.zip': 'application/zip',
|
|
931
|
+
'.pdf': 'application/pdf',
|
|
932
|
+
'.png': 'image/png',
|
|
933
|
+
'.jpg': 'image/jpeg',
|
|
934
|
+
'.jpeg': 'image/jpeg',
|
|
935
|
+
'.gif': 'image/gif',
|
|
936
|
+
'.webp': 'image/webp',
|
|
937
|
+
'.svg': 'image/svg+xml',
|
|
938
|
+
'.json': 'application/json',
|
|
939
|
+
'.txt': 'text/plain',
|
|
940
|
+
'.md': 'text/markdown',
|
|
941
|
+
'.csv': 'text/csv',
|
|
942
|
+
};
|
|
943
|
+
return types[ext] || 'application/octet-stream';
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function uploadFileTool(args, state, paint) {
|
|
947
|
+
if (!args.path || typeof args.path !== 'string') throw new Error('upload_file requiere path');
|
|
948
|
+
const filePath = resolveInputPath(args.path, state.cwd);
|
|
949
|
+
const stats = await fsp.stat(filePath).catch(() => null);
|
|
950
|
+
if (!stats?.isFile()) throw new Error(`Archivo no encontrado: ${filePath}`);
|
|
951
|
+
|
|
952
|
+
const maxBytes = 5 * 1024 * 1024;
|
|
953
|
+
if (stats.size > maxBytes) {
|
|
954
|
+
throw new Error(`El archivo supera el limite de 5 MB (${stats.size} bytes)`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const endpoint = 'https://cdn.soymaycol.icu/upload';
|
|
958
|
+
const displayName = args.name || path.basename(filePath);
|
|
959
|
+
const allowed = await askConfirmation(state.rl, 'Subir archivo a CDN', `${displayName} (${stats.size} bytes) -> ${endpoint}`, paint, state);
|
|
960
|
+
if (!allowed) return 'Subida cancelada.';
|
|
961
|
+
|
|
962
|
+
const buffer = await fsp.readFile(filePath);
|
|
963
|
+
const type = args.type || guessContentType(filePath);
|
|
964
|
+
const form = new FormData();
|
|
965
|
+
form.append(String(args.field || 'file'), new Blob([buffer], { type }), displayName);
|
|
966
|
+
|
|
967
|
+
const controller = new AbortController();
|
|
968
|
+
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
969
|
+
let res;
|
|
970
|
+
try {
|
|
971
|
+
res = await fetch(endpoint, {
|
|
972
|
+
method: 'POST',
|
|
973
|
+
body: form,
|
|
974
|
+
signal: controller.signal,
|
|
975
|
+
});
|
|
976
|
+
} finally {
|
|
977
|
+
clearTimeout(timeout);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const text = await res.text();
|
|
981
|
+
let payload = null;
|
|
982
|
+
try { payload = JSON.parse(text); } catch {}
|
|
983
|
+
if (!res.ok) {
|
|
984
|
+
throw new Error(`Upload fallo (${res.status}): ${shortText(text, 500)}`);
|
|
985
|
+
}
|
|
986
|
+
if (!payload || typeof payload.link !== 'string' || !payload.link.trim()) {
|
|
987
|
+
throw new Error(`Respuesta de upload invalida: ${shortText(text, 500)}`);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return [
|
|
991
|
+
'Archivo subido correctamente.',
|
|
992
|
+
`Nombre: ${payload.name || displayName}`,
|
|
993
|
+
`Tamano: ${payload.size ?? stats.size}`,
|
|
994
|
+
`Tipo: ${payload.type || type}`,
|
|
995
|
+
`Link directo: ${payload.link}`,
|
|
996
|
+
'',
|
|
997
|
+
JSON.stringify(payload, null, 2),
|
|
998
|
+
].join('\n');
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
function decodeBase64UrlText(value = '') {
|
|
1003
|
+
if (!value) return '';
|
|
1004
|
+
const normalized = String(value).replace(/-/g, '+').replace(/_/g, '/');
|
|
1005
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
1006
|
+
return Buffer.from(padded, 'base64').toString('utf8');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function encodeBase64UrlText(value = '') {
|
|
1010
|
+
return Buffer.from(String(value), 'utf8')
|
|
1011
|
+
.toString('base64')
|
|
1012
|
+
.replace(/\+/g, '-')
|
|
1013
|
+
.replace(/\//g, '_')
|
|
1014
|
+
.replace(/=+$/g, '');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function getHeader(headers = [], name) {
|
|
1018
|
+
const found = headers.find(header => String(header.name || '').toLowerCase() === String(name).toLowerCase());
|
|
1019
|
+
return found?.value || '';
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function collectMessageText(payload, out = []) {
|
|
1023
|
+
if (!payload) return out;
|
|
1024
|
+
if (payload.mimeType === 'text/plain' && payload.body?.data) {
|
|
1025
|
+
out.push(decodeBase64UrlText(payload.body.data));
|
|
1026
|
+
}
|
|
1027
|
+
for (const part of payload.parts || []) collectMessageText(part, out);
|
|
1028
|
+
if (out.length === 0 && payload.body?.data) out.push(decodeBase64UrlText(payload.body.data));
|
|
1029
|
+
return out;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function formatMessageSummary(message) {
|
|
1033
|
+
const headers = message.payload?.headers || [];
|
|
1034
|
+
return [
|
|
1035
|
+
`ID: ${message.id}`,
|
|
1036
|
+
`From: ${getHeader(headers, 'From') || '-'}`,
|
|
1037
|
+
`To: ${getHeader(headers, 'To') || '-'}`,
|
|
1038
|
+
`Subject: ${getHeader(headers, 'Subject') || '-'}`,
|
|
1039
|
+
`Date: ${getHeader(headers, 'Date') || '-'}`,
|
|
1040
|
+
`Snippet: ${message.snippet || '-'}`,
|
|
1041
|
+
].join('\n');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function encodeSubject(subject) {
|
|
1045
|
+
const text = String(subject || '');
|
|
1046
|
+
return /^[\x00-\x7F]*$/.test(text)
|
|
1047
|
+
? text
|
|
1048
|
+
: `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function buildRawEmail({ to, subject, body }) {
|
|
1052
|
+
return encodeBase64UrlText([
|
|
1053
|
+
`To: ${String(to || '').trim()}`,
|
|
1054
|
+
`Subject: ${encodeSubject(subject)}`,
|
|
1055
|
+
'MIME-Version: 1.0',
|
|
1056
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
1057
|
+
'Content-Transfer-Encoding: 8bit',
|
|
1058
|
+
'',
|
|
1059
|
+
String(body || ''),
|
|
1060
|
+
].join('\r\n'));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function gmailTool(args = {}, state, paint) {
|
|
1064
|
+
const action = String(args.action || 'status').toLowerCase().trim();
|
|
1065
|
+
|
|
1066
|
+
if (action === 'status') {
|
|
1067
|
+
const status = await getGmailAuthStatus();
|
|
1068
|
+
if (!status.connected) return 'Gmail no conectado. Usa /gmail connect para iniciar sesion.';
|
|
1069
|
+
return [
|
|
1070
|
+
'Gmail conectado.',
|
|
1071
|
+
`Cuenta: ${status.email || 'desconocida'}`,
|
|
1072
|
+
`Scopes: ${status.scopes.join(', ') || '-'}`,
|
|
1073
|
+
`Expira: ${status.expiryDate ? new Date(status.expiryDate).toISOString() : '-'}`,
|
|
1074
|
+
].join('\n');
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (action === 'list') {
|
|
1078
|
+
const maxResults = Math.max(1, Math.min(20, Number(args.maxResults || 10)));
|
|
1079
|
+
const data = await gmailApiRequest('GET', '/users/me/messages', {
|
|
1080
|
+
query: {
|
|
1081
|
+
q: args.query || '',
|
|
1082
|
+
maxResults,
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
1086
|
+
if (messages.length === 0) return 'No se encontraron correos.';
|
|
1087
|
+
const details = [];
|
|
1088
|
+
for (const message of messages) {
|
|
1089
|
+
const full = await gmailApiRequest('GET', `/users/me/messages/${encodeURIComponent(message.id)}`, {
|
|
1090
|
+
query: { format: 'metadata' },
|
|
1091
|
+
});
|
|
1092
|
+
details.push(formatMessageSummary(full));
|
|
1093
|
+
}
|
|
1094
|
+
return [`Correos encontrados: ${details.length}`, '', details.join('\n\n---\n\n')].join('\n');
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (action === 'read') {
|
|
1098
|
+
if (!args.id || typeof args.id !== 'string') throw new Error('gmail read requiere id');
|
|
1099
|
+
const message = await gmailApiRequest('GET', `/users/me/messages/${encodeURIComponent(args.id)}`, {
|
|
1100
|
+
query: { format: 'full' },
|
|
1101
|
+
});
|
|
1102
|
+
const text = collectMessageText(message.payload).join('\n').trim();
|
|
1103
|
+
return [
|
|
1104
|
+
formatMessageSummary(message),
|
|
1105
|
+
'',
|
|
1106
|
+
'Contenido:',
|
|
1107
|
+
truncateText(text || message.snippet || '(sin texto legible)', 8000),
|
|
1108
|
+
].join('\n');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (action === 'send') {
|
|
1112
|
+
if (!args.to || !args.subject || !args.body) throw new Error('gmail send requiere to, subject y body');
|
|
1113
|
+
const allowed = await askConfirmation(
|
|
1114
|
+
state.rl,
|
|
1115
|
+
'Enviar correo por Gmail',
|
|
1116
|
+
`Para: ${args.to}\nAsunto: ${args.subject}\n${shortText(String(args.body), 300)}`,
|
|
1117
|
+
paint,
|
|
1118
|
+
state,
|
|
1119
|
+
);
|
|
1120
|
+
if (!allowed) return 'Envio de Gmail cancelado.';
|
|
1121
|
+
const data = await gmailApiRequest('POST', '/users/me/messages/send', {
|
|
1122
|
+
body: { raw: buildRawEmail(args) },
|
|
1123
|
+
});
|
|
1124
|
+
return [`Correo enviado.`, `ID: ${data.id || '-'}`, `Thread: ${data.threadId || '-'}`].join('\n');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
throw new Error('gmail action invalida. Usa status, list, read o send.');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
906
1130
|
async function scrapeSiteTool(args, state, paint) {
|
|
907
1131
|
if (!args.url || typeof args.url !== 'string') throw new Error('scrape_site requiere url');
|
|
908
1132
|
if (!args.selectors || typeof args.selectors !== 'object') throw new Error('scrape_site requiere selectors objeto');
|
|
@@ -1155,6 +1379,12 @@ async function executeToolCall(call, state, ui) {
|
|
|
1155
1379
|
case 'web_read':
|
|
1156
1380
|
result = await webReadTool(call.args, state, ui.paint);
|
|
1157
1381
|
break;
|
|
1382
|
+
case 'upload_file':
|
|
1383
|
+
result = await uploadFileTool(call.args, state, ui.paint);
|
|
1384
|
+
break;
|
|
1385
|
+
case 'gmail':
|
|
1386
|
+
result = await gmailTool(call.args, state, ui.paint);
|
|
1387
|
+
break;
|
|
1158
1388
|
case 'create_canvas_image':
|
|
1159
1389
|
result = await createCanvasImageTool(call.args, state, ui.paint);
|
|
1160
1390
|
break;
|
package/src/tui/app.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { EventEmitter } from 'events';
|
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const { runAgentTurn } = require('../core/agent');
|
|
8
|
+
const { parseAgentResponse } = require('../core/prompts');
|
|
8
9
|
const { handleLocalCommand, SLASH_COMMANDS } = require('../cli/commands');
|
|
9
10
|
const {
|
|
10
11
|
loadOrCreateSessionState,
|
|
@@ -229,6 +230,20 @@ function parseInline(text) {
|
|
|
229
230
|
return parts;
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
|
|
234
|
+
function normalizeAssistantDisplayText(text) {
|
|
235
|
+
const raw = String(text || '');
|
|
236
|
+
const trimmed = raw.trim();
|
|
237
|
+
if (!trimmed) return raw;
|
|
238
|
+
|
|
239
|
+
const parsed = parseAgentResponse(trimmed);
|
|
240
|
+
if (parsed?.type === 'final' && typeof parsed.content === 'string' && parsed.content.trim()) {
|
|
241
|
+
return parsed.content.trim();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return raw;
|
|
245
|
+
}
|
|
246
|
+
|
|
232
247
|
function InlineLine({ text, color }) {
|
|
233
248
|
const parts = parseInline(text);
|
|
234
249
|
const base = color || T.text;
|
|
@@ -514,13 +529,14 @@ function ThinkingBlock({ text, elapsed, live, width }) {
|
|
|
514
529
|
|
|
515
530
|
function AnswerBlock({ text, live, width }) {
|
|
516
531
|
if (!text) return null;
|
|
532
|
+
const displayText = normalizeAssistantDisplayText(text);
|
|
517
533
|
return h(Box, { flexDirection: 'column', paddingLeft: 3, paddingRight: 3, marginTop: 1 },
|
|
518
534
|
h(Box, { gap: 1, marginBottom: 0 },
|
|
519
535
|
h(Text, { color: T.accentSoft, bold: true }, '\u25c9'),
|
|
520
536
|
h(Text, { color: T.textDim, bold: true }, APP_NAME),
|
|
521
537
|
),
|
|
522
538
|
h(Box, { flexDirection: 'column', paddingLeft: 2 },
|
|
523
|
-
h(MarkdownContent, { text, width: Math.max(40, (width || 80) - 8) }),
|
|
539
|
+
h(MarkdownContent, { text: displayText, width: Math.max(40, (width || 80) - 8) }),
|
|
524
540
|
live ? h(Text, { color: T.accent }, '\u258e') : null,
|
|
525
541
|
),
|
|
526
542
|
);
|