kafox-cli 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.js +4 -2
- package/src/gateway.js +178 -25
- package/src/tui.js +187 -37
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -14,8 +14,10 @@ async function fetchApi(pathname, options = {}) {
|
|
|
14
14
|
return await fetch(url, {
|
|
15
15
|
...options,
|
|
16
16
|
headers: { ...storage.getAuthHeaders(options.headers), ...options.headers },
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// Timeout por tentativa: nao deixa um host travado segurar o login.
|
|
18
|
+
// Mas se o caller passou o proprio signal (ex.: o stream do chat, que
|
|
19
|
+
// pode durar minutos), respeita ele em vez de matar em 12s.
|
|
20
|
+
signal: options.signal || (AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined),
|
|
19
21
|
});
|
|
20
22
|
} catch (error) {
|
|
21
23
|
lastError = error;
|
package/src/gateway.js
CHANGED
|
@@ -117,7 +117,9 @@ function wait(ms, signal) {
|
|
|
117
117
|
resolve(value);
|
|
118
118
|
};
|
|
119
119
|
const timer = setTimeout(() => done(true), ms);
|
|
120
|
-
|
|
120
|
+
// Sem unref(): o backoff precisa manter o event loop vivo. Se este for o
|
|
121
|
+
// unico timer pendente e estiver unref'd, o Node dorme e a CLI so acorda
|
|
122
|
+
// quando o usuario digita algo — o retry ficaria "travado" ate um input.
|
|
121
123
|
function onAbort() {
|
|
122
124
|
done(false);
|
|
123
125
|
}
|
|
@@ -139,7 +141,7 @@ function errorFrom(data, status) {
|
|
|
139
141
|
* Le um stream SSE de chat completions (OpenAI-compatible) e chama os
|
|
140
142
|
* callbacks a cada delta. Devolve o resultado final.
|
|
141
143
|
*/
|
|
142
|
-
async function readStream({ response, started, onDelta, onReasoning }) {
|
|
144
|
+
async function readStream({ response, started, onDelta, onReasoning, skipPrefix = "", signal } = {}) {
|
|
143
145
|
const decoder = new TextDecoder();
|
|
144
146
|
let buffer = "";
|
|
145
147
|
let text = "";
|
|
@@ -147,6 +149,73 @@ async function readStream({ response, started, onDelta, onReasoning }) {
|
|
|
147
149
|
let usage = null;
|
|
148
150
|
const toolCalls = [];
|
|
149
151
|
|
|
152
|
+
// Watchdog de inatividade: o provedor as vezes morre sem FIN e o `for await`
|
|
153
|
+
// do body fica pendurado pra sempre (o event loop dorme e a CLI so acorda no
|
|
154
|
+
// proximo input). Se nenhum byte chega por IDLE_MS, aborta localmente e marca
|
|
155
|
+
// como retryable — o chat() reconecta por continuacao.
|
|
156
|
+
const IDLE_MS = Number(process.env.KAFOX_STREAM_IDLE_MS) || 90_000;
|
|
157
|
+
const idle = new AbortController();
|
|
158
|
+
let idleTimer = null;
|
|
159
|
+
let idleFired = false;
|
|
160
|
+
const clearIdle = () => {
|
|
161
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
162
|
+
idleTimer = null;
|
|
163
|
+
};
|
|
164
|
+
// Quando o watchdog dispara, cancelamos o reader do body: isso desbloqueia o
|
|
165
|
+
// `read()` pendurado (o signal do fetch sozinho nao interrompe um body ja
|
|
166
|
+
// iniciado se a conexao morreu sem FIN).
|
|
167
|
+
let reader = null;
|
|
168
|
+
const onIdleAbort = () => {
|
|
169
|
+
idleFired = true;
|
|
170
|
+
reader?.cancel?.().catch(() => {});
|
|
171
|
+
};
|
|
172
|
+
idle.signal.addEventListener("abort", onIdleAbort, { once: true });
|
|
173
|
+
const bumpIdle = () => {
|
|
174
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
175
|
+
idleTimer = setTimeout(() => idle.abort(new Error("stream idle timeout")), IDLE_MS);
|
|
176
|
+
};
|
|
177
|
+
bumpIdle();
|
|
178
|
+
// Continuacao: o provedor costuma repetir o inicio ja recebido antes de
|
|
179
|
+
// seguir. Segura a emissao ate passar do prefixo para nao duplicar na tela.
|
|
180
|
+
let toSkip = skipPrefix;
|
|
181
|
+
// Texto efetivamente entregue ao onDelta nesta tentativa (sem o prefixo).
|
|
182
|
+
let emitted = "";
|
|
183
|
+
|
|
184
|
+
const emitContent = (chunk) => {
|
|
185
|
+
if (toSkip) {
|
|
186
|
+
if (chunk.length <= toSkip.length) {
|
|
187
|
+
// O trecho novo ainda esta dentro do prefixo repetido: confere se bate.
|
|
188
|
+
if (toSkip.startsWith(chunk)) {
|
|
189
|
+
toSkip = toSkip.slice(chunk.length);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Divergiu do prefixo: o modelo recomecou; emite tudo o que veio.
|
|
193
|
+
toSkip = "";
|
|
194
|
+
emitted += chunk;
|
|
195
|
+
onDelta?.(chunk);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Passou do prefixo: descarta a parte repetida e emite o resto.
|
|
199
|
+
const overlap = chunk.slice(0, toSkip.length);
|
|
200
|
+
if (overlap === toSkip || chunk.startsWith(toSkip)) {
|
|
201
|
+
const rest = chunk.slice(toSkip.length);
|
|
202
|
+
toSkip = "";
|
|
203
|
+
if (rest) {
|
|
204
|
+
emitted += rest;
|
|
205
|
+
onDelta?.(rest);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Nao bateu: recomecou, emite tudo.
|
|
210
|
+
toSkip = "";
|
|
211
|
+
emitted += chunk;
|
|
212
|
+
onDelta?.(chunk);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
emitted += chunk;
|
|
216
|
+
onDelta?.(chunk);
|
|
217
|
+
};
|
|
218
|
+
|
|
150
219
|
const handleLine = (line) => {
|
|
151
220
|
if (!line.startsWith("data:")) return null;
|
|
152
221
|
const data = line.slice(5).trim();
|
|
@@ -172,48 +241,81 @@ async function readStream({ response, started, onDelta, onReasoning }) {
|
|
|
172
241
|
}
|
|
173
242
|
if (delta.content) {
|
|
174
243
|
text += delta.content;
|
|
175
|
-
|
|
244
|
+
emitContent(delta.content);
|
|
176
245
|
}
|
|
177
246
|
if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
|
|
178
247
|
return null;
|
|
179
248
|
};
|
|
180
249
|
|
|
181
250
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
251
|
+
reader = response.body.getReader();
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
bumpIdle();
|
|
256
|
+
buffer += decoder.decode(value, { stream: true });
|
|
184
257
|
let index = buffer.indexOf("\n");
|
|
185
258
|
while (index >= 0) {
|
|
186
259
|
const line = buffer.slice(0, index).replace(/\r$/, "");
|
|
187
260
|
buffer = buffer.slice(index + 1);
|
|
188
261
|
const failure = handleLine(line);
|
|
189
|
-
if (failure) return { ok: false, error: failure.error, retryable: failure.retryable };
|
|
262
|
+
if (failure) { clearIdle(); return { ok: false, error: failure.error, retryable: failure.retryable }; }
|
|
190
263
|
index = buffer.indexOf("\n");
|
|
191
264
|
}
|
|
192
265
|
}
|
|
193
266
|
if (buffer.trim()) handleLine(buffer.trim());
|
|
267
|
+
// O cancel() do watchdog pode encerrar o reader como "done": se foi o
|
|
268
|
+
// watchdog que disparou, isso e falha e deve reconectar.
|
|
269
|
+
if (idleFired) {
|
|
270
|
+
clearIdle();
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: `Stream interrompido: sem resposta do provedor por ${Math.round(IDLE_MS / 1000)}s`,
|
|
274
|
+
text,
|
|
275
|
+
emitted,
|
|
276
|
+
retryable: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
194
279
|
} catch (error) {
|
|
195
|
-
|
|
196
|
-
|
|
280
|
+
clearIdle();
|
|
281
|
+
if (error?.name === "AbortError" || idleFired) {
|
|
282
|
+
// Abort com o signal do usuario ativo = ele apertou Esc: encerra.
|
|
283
|
+
if (signal?.aborted && !idleFired) {
|
|
284
|
+
return { ok: true, text, emitted, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
|
|
285
|
+
}
|
|
286
|
+
// Sem abort do usuario, falha de conexao ou watchdog de inatividade:
|
|
287
|
+
// marca como retryable para o chat() reconectar de onde parou.
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: idleFired
|
|
291
|
+
? `Stream interrompido: sem resposta do provedor por ${Math.round(IDLE_MS / 1000)}s`
|
|
292
|
+
: `Stream interrompido: ${error.message || "conexao caiu"}`,
|
|
293
|
+
text,
|
|
294
|
+
emitted,
|
|
295
|
+
retryable: true,
|
|
296
|
+
};
|
|
197
297
|
}
|
|
198
298
|
return {
|
|
199
299
|
ok: false,
|
|
200
300
|
error: `Stream interrompido: ${error.message}`,
|
|
201
301
|
text,
|
|
302
|
+
emitted,
|
|
202
303
|
retryable: retryableMessage(error.message),
|
|
203
304
|
};
|
|
204
305
|
}
|
|
306
|
+
clearIdle();
|
|
205
307
|
|
|
206
|
-
return { ok: true, text, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
|
|
308
|
+
return { ok: true, text, emitted, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
|
|
207
309
|
}
|
|
208
310
|
|
|
209
311
|
/**
|
|
210
312
|
* Uma tentativa no provedor kafox (gateway proprio): login/saldo/chave da conta
|
|
211
313
|
* e POST em `/api/chat`.
|
|
212
314
|
*/
|
|
213
|
-
async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
|
|
315
|
+
async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal, skipPrefix } = {}) {
|
|
214
316
|
const socket = providerForModel(model);
|
|
215
317
|
if (socket.provider.kind === "openai") {
|
|
216
|
-
return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal });
|
|
318
|
+
return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal, skipPrefix });
|
|
217
319
|
}
|
|
218
320
|
|
|
219
321
|
const ready = await ensureReady({ model });
|
|
@@ -235,11 +337,11 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
|
|
|
235
337
|
signal,
|
|
236
338
|
});
|
|
237
339
|
} catch (error) {
|
|
238
|
-
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
340
|
+
if (error?.name === "AbortError" && signal?.aborted) return { ok: false, code: "aborted", error: "Cancelado." };
|
|
239
341
|
return {
|
|
240
342
|
ok: false,
|
|
241
|
-
error: `Falha de rede: ${error.message}`,
|
|
242
|
-
retryable: retryableMessage(error.message),
|
|
343
|
+
error: error?.name === "AbortError" ? `Falha de rede: ${error.message || "conexao caiu"}` : `Falha de rede: ${error.message}`,
|
|
344
|
+
retryable: error?.name === "AbortError" ? true : retryableMessage(error.message),
|
|
243
345
|
keyCreated: ready.keyCreated,
|
|
244
346
|
};
|
|
245
347
|
}
|
|
@@ -254,7 +356,7 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
|
|
|
254
356
|
};
|
|
255
357
|
}
|
|
256
358
|
|
|
257
|
-
const result = await readStream({ response, started, onDelta, onReasoning });
|
|
359
|
+
const result = await readStream({ response, started, onDelta, onReasoning, skipPrefix, signal });
|
|
258
360
|
return { ...result, model: ready.model, keyCreated: ready.keyCreated };
|
|
259
361
|
}
|
|
260
362
|
|
|
@@ -263,7 +365,7 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
|
|
|
263
365
|
* `{baseUrl}/chat/completions` com a chave do usuario. Sem login/saldo da conta
|
|
264
366
|
* kafox; o custo e cobrado pelo proprio provedor.
|
|
265
367
|
*/
|
|
266
|
-
async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal } = {}) {
|
|
368
|
+
async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal, skipPrefix } = {}) {
|
|
267
369
|
const apiKey = storage.getProviderKey(provider.id);
|
|
268
370
|
if (!apiKey) {
|
|
269
371
|
return {
|
|
@@ -292,11 +394,11 @@ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReas
|
|
|
292
394
|
signal,
|
|
293
395
|
});
|
|
294
396
|
} catch (error) {
|
|
295
|
-
if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
|
|
397
|
+
if (error?.name === "AbortError" && signal?.aborted) return { ok: false, code: "aborted", error: "Cancelado." };
|
|
296
398
|
return {
|
|
297
399
|
ok: false,
|
|
298
|
-
error: `Falha de rede: ${error.message}`,
|
|
299
|
-
retryable: retryableMessage(error.message),
|
|
400
|
+
error: error?.name === "AbortError" ? `Falha de rede: ${error.message || "conexao caiu"}` : `Falha de rede: ${error.message}`,
|
|
401
|
+
retryable: error?.name === "AbortError" ? true : retryableMessage(error.message),
|
|
300
402
|
};
|
|
301
403
|
}
|
|
302
404
|
|
|
@@ -309,42 +411,93 @@ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReas
|
|
|
309
411
|
};
|
|
310
412
|
}
|
|
311
413
|
|
|
312
|
-
const result = await readStream({ response, started, onDelta, onReasoning });
|
|
414
|
+
const result = await readStream({ response, started, onDelta, onReasoning, skipPrefix, signal });
|
|
313
415
|
return { ...result, model, provider: provider.id };
|
|
314
416
|
}
|
|
315
417
|
|
|
316
418
|
/**
|
|
317
419
|
* Chat completions com stream (SSE) via /api/chat, com retry em falha de rede.
|
|
318
420
|
* onRetry avisa a TUI (tentativa, espera) para ela mostrar a contagem.
|
|
421
|
+
*
|
|
422
|
+
* Queda no meio da resposta (o pior caso: ja saiu texto e o stream morre) e
|
|
423
|
+
* reconectada por continuacao: reenvia a conversa com o trecho parcial como
|
|
424
|
+
* assistant e pede para seguir de onde parou. O `skipPrefix` faz o provedor
|
|
425
|
+
* repetir o inicio; o gateway descarta a repeticao, entao a TUI so ve o que
|
|
426
|
+
* e novo. Se a continuacao falhar, devolve o parcial para nao perder nada.
|
|
319
427
|
*/
|
|
320
428
|
async function chat({ messages, model, tools, onDelta, onReasoning, onRetry, signal, retries = RETRY_MAX } = {}) {
|
|
321
429
|
const maxAttempts = Math.max(1, Number(retries) || RETRY_MAX);
|
|
322
430
|
let attempt = 0;
|
|
323
431
|
let last = null;
|
|
324
432
|
let clearedKey = false;
|
|
433
|
+
// Acumulado entre tentativas (modo continuacao).
|
|
434
|
+
let carried = "";
|
|
435
|
+
let working = messages;
|
|
436
|
+
let skipPrefix = "";
|
|
325
437
|
|
|
326
438
|
while (attempt < maxAttempts) {
|
|
327
439
|
attempt += 1;
|
|
328
|
-
const result = await attemptChat({
|
|
440
|
+
const result = await attemptChat({
|
|
441
|
+
messages: working,
|
|
442
|
+
model,
|
|
443
|
+
tools,
|
|
444
|
+
onDelta,
|
|
445
|
+
onReasoning,
|
|
446
|
+
signal,
|
|
447
|
+
skipPrefix,
|
|
448
|
+
});
|
|
329
449
|
|
|
330
|
-
if (result.ok || result.code === "aborted")
|
|
450
|
+
if (result.ok || result.code === "aborted") {
|
|
451
|
+
if (carried && result.ok) {
|
|
452
|
+
// Junta o trecho das tentativas anteriores ao texto final.
|
|
453
|
+
const emitted = result.emitted ?? "";
|
|
454
|
+
const fullText = carried + emitted;
|
|
455
|
+
// Se o provedor mandou texto novo menor que o esperado, ainda vale o
|
|
456
|
+
// que ja foi mostrado; devolve o maior dos dois para o consumidor.
|
|
457
|
+
return { ...result, text: fullText.length >= (result.text || "").length ? fullText : result.text };
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
331
461
|
if (result.code === "invalid_api_key" && !clearedKey) {
|
|
332
462
|
clearedKey = true;
|
|
333
463
|
storage.clearApiKey();
|
|
334
464
|
continue;
|
|
335
465
|
}
|
|
336
|
-
//
|
|
337
|
-
if (!result.retryable
|
|
466
|
+
// Erro nao recuperavel (saldo, chave, modelo): devolve como veio.
|
|
467
|
+
if (!result.retryable) return carried ? { ...result, text: result.text || carried } : result;
|
|
338
468
|
last = result;
|
|
339
469
|
|
|
470
|
+
// Caiu ja com texto na tela: reconecta continuando de onde parou.
|
|
471
|
+
const partial = String(result.text || "");
|
|
472
|
+
if (partial.trim()) {
|
|
473
|
+
carried += partial;
|
|
474
|
+
skipPrefix = carried;
|
|
475
|
+
working = [
|
|
476
|
+
...messages,
|
|
477
|
+
{ role: "assistant", content: carried },
|
|
478
|
+
{
|
|
479
|
+
role: "user",
|
|
480
|
+
content:
|
|
481
|
+
"Sua resposta anterior foi interrompida por uma falha de conexao. " +
|
|
482
|
+
"Continue exatamente de onde parou, sem repetir nada do que ja foi escrito.",
|
|
483
|
+
},
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
|
|
340
487
|
if (attempt >= maxAttempts) break;
|
|
341
488
|
|
|
342
489
|
const delay = backoffMs(attempt);
|
|
343
490
|
onRetry?.({ attempt, maxAttempts, delay, error: result.error });
|
|
344
491
|
const slept = await wait(delay, signal);
|
|
345
|
-
if (!slept)
|
|
492
|
+
if (!slept) {
|
|
493
|
+
return { ok: false, code: "aborted", error: "Cancelado.", text: carried, keyCreated: result.keyCreated };
|
|
494
|
+
}
|
|
346
495
|
}
|
|
347
496
|
|
|
497
|
+
// Esgotou as tentativas: se ja tinha texto, entrega o parcial (melhor que erro).
|
|
498
|
+
if (carried.trim()) {
|
|
499
|
+
return { ...last, ok: true, text: carried, attempts: attempt, partial: true };
|
|
500
|
+
}
|
|
348
501
|
return { ...last, attempts: attempt };
|
|
349
502
|
}
|
|
350
503
|
|
package/src/tui.js
CHANGED
|
@@ -316,8 +316,26 @@ function tableLines(header, aligns = [], body = [], width = 80, theme = "default
|
|
|
316
316
|
return lines;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
// Cache do markdown renderizado por {texto, largura, tema}. Sem isso, cada
|
|
320
|
+
// frame (inclusive a cada tecla) reparseava o markdown de TODAS as mensagens
|
|
321
|
+
// da conversa, o que domina o tempo de render em chats longos.
|
|
322
|
+
const MARKDOWN_CACHE = new Map();
|
|
323
|
+
const MARKDOWN_CACHE_MAX = 400;
|
|
324
|
+
/** Converte Markdown simples em linhas prontas para o terminal (com cache). */
|
|
320
325
|
function markdownLines(text, width, theme = "default") {
|
|
326
|
+
const key = `${width}\u0000${theme}\u0000${text}`;
|
|
327
|
+
const hit = MARKDOWN_CACHE.get(key);
|
|
328
|
+
if (hit) return hit;
|
|
329
|
+
const out = markdownLinesUncached(text, width, theme);
|
|
330
|
+
if (MARKDOWN_CACHE.size >= MARKDOWN_CACHE_MAX) {
|
|
331
|
+
// Despeja o mais antigo (ordem de insercao do Map).
|
|
332
|
+
MARKDOWN_CACHE.delete(MARKDOWN_CACHE.keys().next().value);
|
|
333
|
+
}
|
|
334
|
+
MARKDOWN_CACHE.set(key, out);
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function markdownLinesUncached(text, width, theme = "default") {
|
|
321
339
|
const accent = accentFor(theme);
|
|
322
340
|
const out = [];
|
|
323
341
|
const rows = String(text).replace(/\r/g, "").split("\n");
|
|
@@ -481,6 +499,8 @@ const CONTROL_KEYS = {
|
|
|
481
499
|
12: { name: "l", ctrl: true },
|
|
482
500
|
13: { name: "return" },
|
|
483
501
|
21: { name: "u", ctrl: true },
|
|
502
|
+
// Ctrl+R: atualiza saldo da conta e dos provedores externos agora.
|
|
503
|
+
18: { name: "r", ctrl: true },
|
|
484
504
|
// Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
|
|
485
505
|
17: { name: "q", ctrl: true },
|
|
486
506
|
23: { name: "w", ctrl: true },
|
|
@@ -684,6 +704,7 @@ function initialState() {
|
|
|
684
704
|
modal: null,
|
|
685
705
|
models: [],
|
|
686
706
|
balance: null,
|
|
707
|
+
providerBalances: [],
|
|
687
708
|
tokens: 0,
|
|
688
709
|
files: [],
|
|
689
710
|
mcp: { loading: false, statuses: [], tools: 0 },
|
|
@@ -754,6 +775,8 @@ function heroLines(state, width, available) {
|
|
|
754
775
|
" envia " +
|
|
755
776
|
chalk.dim("/") +
|
|
756
777
|
" menu " +
|
|
778
|
+
chalk.dim("ctrl+r") +
|
|
779
|
+
" saldo " +
|
|
757
780
|
chalk.dim("ctrl+c") +
|
|
758
781
|
" sai",
|
|
759
782
|
width
|
|
@@ -1082,40 +1105,81 @@ function compactionLines(message, width, state) {
|
|
|
1082
1105
|
return lines;
|
|
1083
1106
|
}
|
|
1084
1107
|
|
|
1085
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* Virtualizacao de mensagens.
|
|
1110
|
+
*
|
|
1111
|
+
* Sem isso, cada frame (inclusive a cada tecla) reconstruia linha a linha TODO
|
|
1112
|
+
* o historico da conversa; em chats longos isso domina o tempo de render e
|
|
1113
|
+
* trava a digitacao. Aqui cada mensagem tem suas linhas memorizadas (chaveadas
|
|
1114
|
+
* por texto/largura/tema + o estado que muda o desenho) e so as mensagens que
|
|
1115
|
+
* caem no viewport sao realmente remontadas.
|
|
1116
|
+
*/
|
|
1117
|
+
const MESSAGE_CACHE = new Map();
|
|
1118
|
+
const MESSAGE_CACHE_MAX = 600;
|
|
1119
|
+
|
|
1120
|
+
/** Chave de cache de uma mensagem: tudo que muda o desenho dela. */
|
|
1121
|
+
function messageCacheKey(message, width, state) {
|
|
1122
|
+
const streaming = state.streaming;
|
|
1123
|
+
const extra =
|
|
1124
|
+
message.role === "tool"
|
|
1125
|
+
? `${message.status || ""}\u0001${message.duration || ""}\u0001${state.toolCompact ? 1 : 0}`
|
|
1126
|
+
: message.role === "thinking"
|
|
1127
|
+
? `${message.done ? 1 : 0}`
|
|
1128
|
+
: "";
|
|
1129
|
+
return `${width}\u0000${state.theme}\u0000${message.role}\u0000${extra}\u0000${message.text}`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Linhas de UMA mensagem (com cache). */
|
|
1133
|
+
function messageBlock(message, width, state) {
|
|
1134
|
+
const key = messageCacheKey(message, width, state);
|
|
1135
|
+
const hit = MESSAGE_CACHE.get(key);
|
|
1136
|
+
if (hit) return hit;
|
|
1137
|
+
const out = messageBlockUncached(message, width, state);
|
|
1138
|
+
if (MESSAGE_CACHE.size >= MESSAGE_CACHE_MAX) {
|
|
1139
|
+
MESSAGE_CACHE.delete(MESSAGE_CACHE.keys().next().value);
|
|
1140
|
+
}
|
|
1141
|
+
MESSAGE_CACHE.set(key, out);
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function messageBlockUncached(message, width, state) {
|
|
1086
1146
|
const lines = [];
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const mark = message.interrupted ? "⏹" : "!";
|
|
1114
|
-
for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
|
|
1115
|
-
}
|
|
1147
|
+
if (message.role === "user") {
|
|
1148
|
+
lines.push("");
|
|
1149
|
+
const body = wrap(message.text, width - 2);
|
|
1150
|
+
lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
|
|
1151
|
+
for (let index = 1; index < body.length; index += 1) {
|
|
1152
|
+
lines.push(indent(4) + chalk.bold(body[index]));
|
|
1153
|
+
}
|
|
1154
|
+
} else if (message.role === "compaction") {
|
|
1155
|
+
lines.push(...compactionLines(message, width, state));
|
|
1156
|
+
} else if (message.role === "assistant") {
|
|
1157
|
+
lines.push("");
|
|
1158
|
+
lines.push(...markdownLines(message.text, width - 2, state.theme));
|
|
1159
|
+
const meta = [];
|
|
1160
|
+
if (message.interrupted) meta.push("interrompido");
|
|
1161
|
+
if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
|
|
1162
|
+
if (meta.length) lines.push("");
|
|
1163
|
+
if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
|
|
1164
|
+
} else if (message.role === "thinking") {
|
|
1165
|
+
lines.push(...thinkingLines(message, width));
|
|
1166
|
+
} else if (message.role === "tool") {
|
|
1167
|
+
lines.push(...toolCardLines(message, width, state));
|
|
1168
|
+
} else {
|
|
1169
|
+
lines.push("");
|
|
1170
|
+
const color = message.error ? chalk.red : message.interrupted ? chalk.yellow : chalk.dim;
|
|
1171
|
+
const mark = message.interrupted ? "⏹" : "!";
|
|
1172
|
+
for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
|
|
1116
1173
|
}
|
|
1174
|
+
return lines;
|
|
1175
|
+
}
|
|
1117
1176
|
|
|
1118
|
-
|
|
1177
|
+
/**
|
|
1178
|
+
* "Cauda" da conversa: bloco de streaming, indicador de "trabalhando" etc.
|
|
1179
|
+
* Fica separado do historico porque muda a cada token e nao vale cachear.
|
|
1180
|
+
*/
|
|
1181
|
+
function messageTailLines(state, width) {
|
|
1182
|
+
const lines = [];
|
|
1119
1183
|
const streaming = state.streaming;
|
|
1120
1184
|
if (streaming && (streaming.reasoning || streaming.text)) {
|
|
1121
1185
|
if (streaming.reasoning) {
|
|
@@ -1134,7 +1198,6 @@ function messageLines(state, width) {
|
|
|
1134
1198
|
lines.push(...markdownLines(streaming.text, width - 2, state.theme));
|
|
1135
1199
|
}
|
|
1136
1200
|
}
|
|
1137
|
-
|
|
1138
1201
|
if (state.busy && !streaming?.text) {
|
|
1139
1202
|
lines.push("");
|
|
1140
1203
|
lines.push(indent(2) + chalk.dim("trabalhando" + ".".repeat(state.frame % 4)));
|
|
@@ -1142,6 +1205,47 @@ function messageLines(state, width) {
|
|
|
1142
1205
|
return lines;
|
|
1143
1206
|
}
|
|
1144
1207
|
|
|
1208
|
+
/**
|
|
1209
|
+
* Monta o corpo da conversa gastando o minimo: processa mensagens de tras pra
|
|
1210
|
+
* frente ate cobrir o viewport pedido (ou ate acabar o historico). Fora do
|
|
1211
|
+
* viewport nem tocamos no markdown. Devolve o array ja "padded" no topo para
|
|
1212
|
+
* preencher a altura do terminal.
|
|
1213
|
+
*/
|
|
1214
|
+
function messageLines(state, width, budget = Infinity) {
|
|
1215
|
+
const need = budget === Infinity ? Infinity : Math.max(0, budget);
|
|
1216
|
+
const blocks = [];
|
|
1217
|
+
let total = 0;
|
|
1218
|
+
|
|
1219
|
+
const tail = messageTailLines(state, width);
|
|
1220
|
+
if (tail.length) {
|
|
1221
|
+
blocks.push({ start: 0, lines: tail });
|
|
1222
|
+
total += tail.length;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
for (let index = state.messages.length - 1; index >= 0; index -= 1) {
|
|
1226
|
+
const message = state.messages[index];
|
|
1227
|
+
const lines = messageBlock(message, width, state);
|
|
1228
|
+
blocks.push({ lines });
|
|
1229
|
+
total += lines.length;
|
|
1230
|
+
// Ja cobrimos o viewport (com uma folga para o padding do topo)? Paramos.
|
|
1231
|
+
if (total >= need + 4) break;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const lines = [];
|
|
1235
|
+
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
|
1236
|
+
lines.push(...blocks[index].lines);
|
|
1237
|
+
}
|
|
1238
|
+
return lines;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function messageLinesFull(state, width) {
|
|
1242
|
+
const lines = [];
|
|
1243
|
+
for (const message of state.messages) lines.push(...messageBlock(message, width, state));
|
|
1244
|
+
lines.push(...messageTailLines(state, width));
|
|
1245
|
+
return lines;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
|
|
1145
1249
|
function filteredCommands(state, commands) {
|
|
1146
1250
|
if (state.menuDismissed) return [];
|
|
1147
1251
|
if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
|
|
@@ -1228,6 +1332,22 @@ function sidebarLines(state, width) {
|
|
|
1228
1332
|
lines.push(chalk.dim(`${formatTokens(tokens)} tokens · ${messages} msg${messages === 1 ? "" : "s"}`));
|
|
1229
1333
|
lines.push(chalk.dim(state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`));
|
|
1230
1334
|
|
|
1335
|
+
// Saldo dos provedores externos (unlid etc.): so aparece quando o backend
|
|
1336
|
+
// consegue ler. Moeda vem do provedor; "sem consulta" quando falha.
|
|
1337
|
+
if (state.balanceLoading) lines.push(chalk.dim("atualizando…"));
|
|
1338
|
+
for (const item of state.providerBalances || []) {
|
|
1339
|
+
const label = truncate(item.label || item.id || "provider", Math.max(6, width - 12));
|
|
1340
|
+
if (item.ok && item.balance != null) {
|
|
1341
|
+
const money =
|
|
1342
|
+
item.currency === "BRL"
|
|
1343
|
+
? `R$ ${Number(item.balance).toFixed(2)}`
|
|
1344
|
+
: `${Number(item.balance).toFixed(2)} ${item.currency || "USD"}`;
|
|
1345
|
+
lines.push(chalk.dim(`${label} `) + chalk.cyan(money));
|
|
1346
|
+
} else {
|
|
1347
|
+
lines.push(chalk.dim(`${label} `) + chalk.dim("--"));
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1231
1351
|
lines.push("");
|
|
1232
1352
|
lines.push(border(state, "MCP"));
|
|
1233
1353
|
if (state.mcp.loading) {
|
|
@@ -1636,7 +1756,7 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1636
1756
|
const bodyHeight = Math.max(0, rows - head.length - chrome.length);
|
|
1637
1757
|
state.pageSize = Math.max(1, bodyHeight);
|
|
1638
1758
|
|
|
1639
|
-
let body = hero ? [] : messageLines(state, inner);
|
|
1759
|
+
let body = hero ? [] : messageLines(state, inner, bodyHeight + state.scroll);
|
|
1640
1760
|
const maxScroll = Math.max(0, body.length - bodyHeight);
|
|
1641
1761
|
state.scroll = clamp(state.scroll, 0, maxScroll);
|
|
1642
1762
|
const end = body.length - state.scroll;
|
|
@@ -1755,19 +1875,35 @@ async function runTUI({
|
|
|
1755
1875
|
renderTimer.unref?.();
|
|
1756
1876
|
}
|
|
1757
1877
|
|
|
1878
|
+
// Digitar/apagar dispara uma tecla por evento: render() sincrono a cada tecla
|
|
1879
|
+
// reconstroi a conversa inteira e trava o input em chats longos. Este
|
|
1880
|
+
// agendador curto (~60fps) mantem a resposta visual imediata e coalesce
|
|
1881
|
+
// rajadas (segurar backspace/colar) num unico frame.
|
|
1882
|
+
let inputTimer = null;
|
|
1883
|
+
function scheduleInputRender() {
|
|
1884
|
+
if (inputTimer || closed) return;
|
|
1885
|
+
inputTimer = setTimeout(() => {
|
|
1886
|
+
inputTimer = null;
|
|
1887
|
+
render();
|
|
1888
|
+
}, 16);
|
|
1889
|
+
inputTimer.unref?.();
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1758
1892
|
const mouseMode = () => (state.mouse ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
|
|
1759
1893
|
const enterScreen = () =>
|
|
1760
1894
|
write(`\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J${mouseMode()}\x1b[?2004h`);
|
|
1761
1895
|
const leaveScreen = () =>
|
|
1762
1896
|
write("\x1b[?2004l\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
1763
1897
|
|
|
1764
|
-
async function refreshAccount() {
|
|
1898
|
+
async function refreshAccount({ fresh = false } = {}) {
|
|
1765
1899
|
const token = await ensureSession();
|
|
1766
1900
|
if (!token) return;
|
|
1767
|
-
const response = await fetchApi(
|
|
1901
|
+
const response = await fetchApi(`/api/bootstrap${fresh ? "?fresh=1" : ""}`).catch(() => null);
|
|
1768
1902
|
if (!response || !response.ok) return;
|
|
1769
1903
|
const data = await response.json().catch(() => null);
|
|
1770
1904
|
if (data?.user) state.balance = data.user.balance_micros;
|
|
1905
|
+
// Saldo (credits) dos provedores externos, ex.: unlid/uncensored.
|
|
1906
|
+
state.providerBalances = Array.isArray(data?.provider_balances) ? data.provider_balances : [];
|
|
1771
1907
|
|
|
1772
1908
|
if (!state.model) {
|
|
1773
1909
|
const pricing = await fetchApi("/api/public/pricing")
|
|
@@ -1777,6 +1913,19 @@ async function runTUI({
|
|
|
1777
1913
|
}
|
|
1778
1914
|
}
|
|
1779
1915
|
|
|
1916
|
+
/** Atalho/botão de atualizar: re-busca saldo da conta e dos provedores. */
|
|
1917
|
+
async function refreshBalance() {
|
|
1918
|
+
if (state.balanceLoading) return;
|
|
1919
|
+
state.balanceLoading = true;
|
|
1920
|
+
render();
|
|
1921
|
+
try {
|
|
1922
|
+
await refreshAccount({ fresh: true });
|
|
1923
|
+
} finally {
|
|
1924
|
+
state.balanceLoading = false;
|
|
1925
|
+
render();
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1780
1929
|
function applyMcp() {
|
|
1781
1930
|
state.mcp.statuses = mcp.statuses();
|
|
1782
1931
|
state.mcp.tools = state.mcp.statuses.reduce((total, item) => total + item.tools, 0);
|
|
@@ -2617,7 +2766,7 @@ async function runTUI({
|
|
|
2617
2766
|
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
2618
2767
|
state.menuIndex = 0;
|
|
2619
2768
|
state.menuDismissed = false;
|
|
2620
|
-
|
|
2769
|
+
scheduleInputRender();
|
|
2621
2770
|
}
|
|
2622
2771
|
|
|
2623
2772
|
function moveApproval(delta) {
|
|
@@ -2682,6 +2831,7 @@ async function runTUI({
|
|
|
2682
2831
|
|
|
2683
2832
|
if (key.name === "f2") return toggleMouse();
|
|
2684
2833
|
if (key.ctrl && key.name === "y") return copyLastAnswer();
|
|
2834
|
+
if (key.ctrl && key.name === "r") return refreshBalance();
|
|
2685
2835
|
|
|
2686
2836
|
// Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
|
|
2687
2837
|
// input em vez de ser engolido (quem digita nao perde o que escreveu).
|