kafox-cli 0.10.1 → 0.10.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
5
  "repository": {
6
6
  "type": "git",
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
- // timeout por tentativa: nao deixa um host travado segurar o login.
18
- signal: AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined,
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/cli.js CHANGED
@@ -4,8 +4,11 @@ const path = require("path");
4
4
  const inquirer = require("inquirer");
5
5
  const chalk = require("chalk");
6
6
  const storage = require("./storage");
7
+ const promptHistory = require("./prompt-history");
7
8
  const { ensureSession, fetchApi } = require("./auth");
8
- const { ask } = require("./gateway");
9
+ const { chat } = require("./gateway");
10
+ const { compactIfNeeded, trimHistory } = require("./compact");
11
+ const sessions = require("./sessions");
9
12
  const { runTUI } = require("./tui");
10
13
  const { runTheme, setContentBackground } = require("./theme");
11
14
  const { logoText, logoLines } = require("./logo");
@@ -38,12 +41,61 @@ const COMMANDS = {
38
41
 
39
42
  let history = [];
40
43
  let modelOverride = null;
44
+ // Sessao do REPL sem TTY: e o que permite `kafox resume`/`kafox history`
45
+ // acharem uma conversa usada por pipe, script ou CI (sem passar pela TUI).
46
+ let plainSession = null;
41
47
 
42
48
  function pushHistory(role, content) {
43
49
  history.push({ role, content, at: new Date().toISOString() });
44
50
  if (history.length > 200) history = history.slice(-200);
45
51
  }
46
52
 
53
+ /**
54
+ * Historico no formato OpenAI: e isso que vai para o provedor.
55
+ *
56
+ * Sem esse mapeamento o REPL sem TTY mandava so a linha digitada em cada
57
+ * requisicao, entao a IA respondia como se a conversa tivesse acabado de
58
+ * comecar. Mensagens seguidas do mesmo papel viram uma so (o provedor recusa
59
+ * duas em sequencia) e o system gerado pelo /compact e preservado.
60
+ */
61
+ function conversationMessages(entries = history) {
62
+ const out = [];
63
+ for (const entry of entries) {
64
+ const role = String(entry?.role || "");
65
+ if (role !== "system" && role !== "user" && role !== "assistant") continue;
66
+ const content = String(entry?.content || "").trim();
67
+ if (!content) continue;
68
+ const last = out[out.length - 1];
69
+ if (last && last.role === role && role !== "system") last.content += `\n\n${content}`;
70
+ else out.push({ role, content });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** Conversa visivel, no formato que `sessions.save` e `kafox history` leem. */
76
+ function displayMessages() {
77
+ return history.map((entry) => ({ role: entry.role, text: entry.content }));
78
+ }
79
+
80
+ /** Grava a conversa do REPL sem TTY em ~/.kafox/chats (best-effort). */
81
+ function savePlainSession() {
82
+ if (!plainSession) return;
83
+ try {
84
+ sessions.save({
85
+ id: plainSession.id,
86
+ cwd: process.cwd(),
87
+ model: modelOverride || storage.getConfig().model,
88
+ theme: storage.getConfig().theme,
89
+ allowAll: false,
90
+ messages: displayMessages(),
91
+ context: conversationMessages(),
92
+ startedAt: plainSession.startedAt,
93
+ });
94
+ } catch {
95
+ // Persistencia e conveniencia: falha de disco nao derruba o chat.
96
+ }
97
+ }
98
+
47
99
  async function handleCommand(line) {
48
100
  const [cmd, ...rest] = line.trim().split(/\s+/);
49
101
  const arg = rest.join(" ");
@@ -178,6 +230,7 @@ async function handleCommand(line) {
178
230
  return true;
179
231
  case "/exit":
180
232
  case "/quit":
233
+ savePlainSession();
181
234
  process.exit(0);
182
235
  default:
183
236
  console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
@@ -187,24 +240,35 @@ async function handleCommand(line) {
187
240
 
188
241
  async function clearContext() {
189
242
  history = [];
243
+ savePlainSession();
190
244
  console.log(chalk.green("Context cleared."));
191
245
  }
192
246
 
193
247
  async function compact() {
194
- if (!history.length) {
248
+ const messages = conversationMessages();
249
+ if (!messages.length) {
195
250
  console.log(chalk.dim("Nothing to compact."));
196
251
  return;
197
252
  }
198
- const turns = history.length;
199
- const last = history.slice(-6);
200
- history = [
201
- {
202
- role: "system",
203
- content: `(context compacted: ${turns} messages summarized)`,
204
- at: new Date().toISOString(),
205
- },
206
- ...last,
207
- ];
253
+ const turns = messages.length;
254
+ // Mesmo resumo estruturado da TUI: o texto antigo vira nota de contexto em
255
+ // vez de sumir (o comportamento antigo so guardava um aviso vazio).
256
+ const result = await compactIfNeeded(messages, {
257
+ model: modelOverride || storage.getConfig().model,
258
+ // 0 forca o resumo: quem digitou /compact quer compactar agora.
259
+ thresholdTokens: 0,
260
+ });
261
+ if (!result.compacted) {
262
+ console.log(chalk.yellow(`Nothing to compact: ${result.error || "resumo vazio"}`));
263
+ return;
264
+ }
265
+ const at = new Date().toISOString();
266
+ history = result.messages.map((message) => ({
267
+ role: message.role,
268
+ content: String(message.content || ""),
269
+ at,
270
+ }));
271
+ savePlainSession();
208
272
  console.log(
209
273
  chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
210
274
  );
@@ -234,13 +298,40 @@ async function showStatus() {
234
298
  }
235
299
 
236
300
  /** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
237
- async function runPlainREPL() {
301
+ async function runPlainREPL(options = {}) {
238
302
  const cfg = storage.getConfig();
239
303
  const theme = cfg.theme || "default";
304
+ history = [];
305
+ plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
306
+
307
+ // `kafox resume <codigo>` tambem vale sem TTY: antes o pipe/script abria
308
+ // sempre uma conversa vazia, mesmo com a sessao salva no disco.
309
+ if (options.resume) {
310
+ const wanted = options.resume === "pick" || options.resume === true ? "last" : options.resume;
311
+ const saved = String(wanted) === "last" ? sessions.latest() : sessions.find(wanted);
312
+ if (saved) {
313
+ plainSession = { id: saved.id, startedAt: saved.startedAt || plainSession.startedAt };
314
+ const restored =
315
+ Array.isArray(saved.context) && saved.context.length
316
+ ? saved.context
317
+ : sessions.contextFromMessages(saved.messages);
318
+ const at = new Date().toISOString();
319
+ history = restored
320
+ .filter((message) => ["system", "user", "assistant"].includes(message?.role))
321
+ .map((message) => ({ role: message.role, content: String(message.content || ""), at }));
322
+ } else {
323
+ console.log(chalk.yellow(` Conversa ${wanted} nao encontrada - comecando uma nova.`));
324
+ }
325
+ }
240
326
 
241
327
  console.log(logoLines(theme).join("\n"));
242
328
  console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
243
329
  console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
330
+ if (history.length) {
331
+ console.log(
332
+ chalk.dim(` Retomando ${plainSession.id} (${conversationMessages().length} mensagens no contexto).`)
333
+ );
334
+ }
244
335
 
245
336
  const token = await ensureSession().catch(() => null);
246
337
  if (!token) {
@@ -268,35 +359,68 @@ async function runPlainREPL() {
268
359
  input: process.stdin,
269
360
  output: process.stdout,
270
361
  prompt: chalk.cyan("kafox> "),
362
+ // O readline guarda o mais recente no começo da lista.
363
+ history: promptHistory.load().slice().reverse(),
364
+ historySize: promptHistory.MAX,
365
+ removeHistoryDuplicates: true,
271
366
  });
272
367
 
273
- rl.prompt();
274
- rl.on("line", async (raw) => {
368
+ async function handleLine(raw) {
275
369
  const line = raw.trim();
276
- if (!line) return rl.prompt();
370
+ if (!line) return;
277
371
  try {
278
372
  if (line.startsWith("/")) {
279
373
  await handleCommand(line);
280
374
  } else {
281
375
  pushHistory("user", line);
282
- await ask(line, { model: modelOverride || storage.getConfig().model });
283
- pushHistory("assistant", "(answer above)");
376
+ promptHistory.push(line);
377
+ // O historico inteiro (nao so a ultima linha) e o que faz a IA lembrar
378
+ // do que ja foi dito nesta conversa.
379
+ const result = await chat({
380
+ messages: trimHistory(conversationMessages()),
381
+ model: modelOverride || storage.getConfig().model,
382
+ });
383
+ if (!result.ok) {
384
+ console.log(chalk.red(`\n${result.error}`));
385
+ } else {
386
+ const answer = String(result.text || "").trim() || "(resposta vazia)";
387
+ console.log(chalk.green("\nAnswer:"));
388
+ console.log(answer);
389
+ pushHistory("assistant", answer);
390
+ }
391
+ savePlainSession();
284
392
  }
285
393
  } catch (err) {
286
394
  console.log(chalk.red(`\nError: ${err.message}`));
287
395
  }
288
- rl.prompt();
396
+ if (!rl.closed) rl.prompt();
397
+ }
398
+
399
+ // Num pipe o stdin chega inteiro de uma vez: processar as linhas em paralelo
400
+ // embaralhava o historico (a segunda nao via a resposta da primeira).
401
+ let pending = Promise.resolve();
402
+ rl.prompt();
403
+ rl.on("line", (raw) => {
404
+ pending = pending.then(() => handleLine(raw));
405
+ return pending;
289
406
  });
290
407
 
291
- rl.on("close", () => {
292
- console.log(chalk.dim("\nSee you."));
408
+ rl.on("close", async () => {
409
+ // EOF do pipe nao pode cortar a ultima resposta no meio: antes o processo
410
+ // saia antes do POST /api/chat terminar e o chat ficava sem resposta.
411
+ await pending.catch(() => {});
412
+ savePlainSession();
413
+ if (plainSession && conversationMessages().length) {
414
+ console.log(chalk.dim(`\nConversa salva: kafox resume ${plainSession.id}`));
415
+ }
416
+ console.log(chalk.dim("See you."));
293
417
  process.exit(0);
294
418
  });
295
419
  }
296
420
 
297
421
  async function runREPL(options = {}) {
298
422
  // Sem TTY (pipe/CI), mantem o REPL linha a linha.
299
- if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
423
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL(options);
300
424
 
301
425
  const cfg = storage.getConfig();
302
426
  const token = await ensureSession().catch(() => null);
@@ -332,5 +456,6 @@ module.exports = {
332
456
  clearContext,
333
457
  showStatus,
334
458
  handleCommand,
459
+ conversationMessages,
335
460
  COMMANDS,
336
461
  };
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
- timer.unref?.();
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
- onDelta?.(delta.content);
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
- for await (const chunk of response.body) {
183
- buffer += decoder.decode(chunk, { stream: true });
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
- if (error?.name === "AbortError") {
196
- return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
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({ messages, model, tools, onDelta, onReasoning, signal });
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") return result;
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
- // Ja saiu texto: repetir duplicaria a resposta na tela.
337
- if (!result.retryable || result.text) return result;
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) return { ok: false, code: "aborted", error: "Cancelado.", keyCreated: result.keyCreated };
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