qwenproxy-cli 1.0.2 → 1.0.4

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 CHANGED
@@ -7,9 +7,10 @@
7
7
  Gateway e API de alta performance compatível com **OpenAI** e **Anthropic** que conecta clientes e agentes (Codex, Claude Code CLI, Grok, Cursor) ao **Qwen (`chat.qwen.ai`)** com suporte a múltiplas contas, failover inteligente, tool calling robusto, thread-native, geração de fotos e vídeos, **Responses API completa com memória persistente** e sessões persistentes. Inclui Playwright com stealth, retries para erros transitórios, variantes públicas base/`-fast`/`-thinking`, cache comprimido, registro de capabilities por modelo e observabilidade.
8
8
 
9
9
  [![CI](https://github.com/johngbl/QwenProxy/actions/workflows/ci.yml/badge.svg)](https://github.com/johngbl/QwenProxy/actions/workflows/ci.yml)
10
+ [![npm version](https://img.shields.io/npm/v/qwenproxy-cli.svg)](https://www.npmjs.com/package/qwenproxy-cli)
10
11
  [![TypeScript](https://img.shields.io/badge/TypeScript-7.0-blue)](https://www.typescriptlang.org/)
11
12
  [![Hono](https://img.shields.io/badge/Hono-4.13-green)](https://hono.dev/)
12
- [![Playwright](https://img.shields.io/badge/Playwright-1.62-blueviolet)](https://playwright.dev/)
13
+ [![Patchright](https://img.shields.io/badge/Patchright-Stealth-blueviolet)](https://github.com/kaliiiiiiiiii/patchright)
13
14
  [![License: ISC](https://img.shields.io/badge/License-ISC-yellow.svg)](LICENSE)
14
15
  [![GitHub Sponsors](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/johngbl)
15
16
  [![Ko-fi](https://img.shields.io/badge/Donate-Ko--fi-ff5e5b?logo=kofi&logoColor=white)](https://ko-fi.com/johngbl)
@@ -29,10 +30,11 @@ Toda contribuição é muito bem-vinda e ajuda a cobrir custos de infraestrutura
29
30
  - **Compatibilidade OpenAI & Anthropic** — `/v1/chat/completions`, `/v1/completions` (legado), `/v1/models`, `/v1/messages` (**Anthropic Messages API** nativa com suporte total a **Claude Code CLI** e `@anthropic-ai/sdk`), `/v1/messages/count_tokens` e **Responses API** `/v1/responses`.
30
31
  - **Responses API completa** — SSE com `event:` + `data:` + `sequence_number`, memória persistente via `previous_response_id` (SQLite durável), `last_response_id`, multimodal (`input_image`/`input_file`), reasoning effort normalization, lifecycle events de reasoning e usage real do upstream.
31
32
  - **Thread-native** — Reutiliza sessão/pai no Qwen; preservação de contexto entre turns
32
- - **Dois modos de conversa** — `thread` (default, reutiliza chat e envia delta) e `temp` (novo chat temporário `chat_mode:"local"` por request, envia histórico completo; zero `chat_in_progress` e zero chats órfãos)
33
- - **Playwright + stealth** — Headers reais (`bx-ua`, `bx-umidtoken`, `bx-v`) por conta; fingerprint estável e cleanup de processos.
34
- - **Transporte Qwen via Chromium** — No fluxo principal de chat, modelos, criação de sessão, personalização, completion e stop usam o contexto Playwright; o completion o `ReadableStream` incrementalmente e preserva o SSE sem bufferizar a resposta inteira.
35
- - **Startup rápido multi-conta** — Sobe com a **primeira conta pronta**; as demais continuam preparando em background.
33
+ - **Três modos de conversa** — `thread` (default, encadeamento nativo e delta de turns), `temp-thread` (recomendado para chat/TUI, efêmero `chat_mode:"local"` com contexto contínuo, zero poluição no `chat.qwen.ai`), e `temp` (stateless padrão OpenAI, novo chat efêmero por requisição).
34
+ - **Instância única de Chromium ultra-leve** — 1 único processo de navegador compartilhado para todas as contas com isolamento seguro de `BrowserContext` e `storageState` JSON, consumindo ~200MB de RAM (economia de >65%).
35
+ - **Dashboard TUI completo & responsivo** — Interface visual interativa no terminal (`qpx`) com monitoramento em tempo real, abas dedicadas para Chat, Sincronização de Agentes, Diagnóstico de Armazenamento, Gerenciamento de Contas e Logs, com confirmações de segurança para ações irreversíveis.
36
+ - **Atualizador inteligente embutido** — Comando `qpx update` que detecta automaticamente seu gerenciador de pacotes (`npm`, `pnpm`, `bun`, `yarn`) e atualiza com um único comando.
37
+ - **Startup sob demanda e multi-conta** — Sobe instantaneamente com a **primeira conta pronta**; as contas reservas ficam em repouso (*Standby*) e inicializam sem esforço apenas sob demanda.
36
38
  - **Retries resilientes** — 502/503/504, erros de rede (`fetch failed`), anti-bot, quota e `invalid_input` com recriação de chat.
37
39
  - **Parser de tools robusto** — stream fragmentado, JSON malformado, fuzzy de nomes (`readFile` → `read_file`), JSON duplamente escapado e `</tool_call>` case-insensitive.
38
40
  - **Personalization sync** — system + tools completos são sincronizados em `/settings/personalization` via `POST /api/v2/users/user/settings/update`; o cache por conteúdo evita updates repetidos e instruções acima do limite seguem inline; aplica settings seguras (`largeTextAsFile=false`, memory off, tools internas off).
@@ -86,19 +88,19 @@ Se `API_KEY` estiver definido, as rotas `/v1/*` (e `/metrics`) exigem uma das fo
86
88
  - `Authorization: Bearer <API_KEY>` (OpenAI / Responses)
87
89
  - `x-api-key: <API_KEY>` (clients bearer-style)
88
90
 
89
- QwenProxy usa **Playwright por padrão**. Cada conta abre uma sessão real de browser para capturar cookies e headers anti-bot.
91
+ QwenProxy utiliza **Patchright com arquitetura de navegador compartilhado**. Um único processo Chromium é mantido aberto com aceleração WebGL ativa, enquanto cada conta opera em um `BrowserContext` isolado com persistência leve em `storage_state.json` (~200MB de RAM para todas as contas).
90
92
 
91
93
  ```env
92
94
  PLAYWRIGHT_HEADLESS=true
93
95
  PLAYWRIGHT_BROWSER=chromium
94
96
  ```
95
97
 
96
- **Requisitos:**
98
+ **Instalação do navegador:**
99
+ O Chromium é instalado automaticamente na primeira execução de `qpx`. Se desejar instalar manualmente:
97
100
 
98
101
  ```bash
99
- npx playwright install chromium
102
+ npx patchright install chromium
100
103
  ```
101
-
102
104
  Senhas das contas são armazenadas **criptografadas** no SQLite (`data/`).
103
105
 
104
106
  ### Transporte upstream e streaming
@@ -272,6 +274,19 @@ qpx
272
274
  ```
273
275
  *(Abre diretamente o dashboard interativo da TUI com o servidor e proxy integrados).*
274
276
 
277
+ #### Comandos Rápidos do CLI (`qpx`)
278
+
279
+ | Comando | Descrição |
280
+ | :--- | :--- |
281
+ | `qpx` *(ou `qwenproxy`)* | Abre o dashboard visual interativo da TUI com servidor proxy integrado |
282
+ | `qpx start` *(ou `--server`)* | Inicia apenas o servidor HTTP/SSE em modo headless (sem interface gráfica) |
283
+ | `qpx update` | Verifica e atualiza o QwenProxy automaticamente via npm/pnpm/bun |
284
+ | `qpx login` | Abre navegador visível para autenticar novas contas interativamente |
285
+ | `qpx sync` | Configura e sincroniza clientes (Claude Code, Codex, OpenCode, OMP) |
286
+ | `qpx clean` | Limpa caches temporários dos perfis Chromium (~4.5MB por conta) |
287
+ | `qpx clean:all` | Limpa caches e remove versões antigas de navegadores órfãos no SSD |
288
+ | `qpx purge` | Limpa o histórico de conversas remotas no Qwen de todas as contas |
289
+ | `qpx reset` | Reseta cooldowns e rate limits salvos no banco de dados |
275
290
  ### Opção 2: Execução Instantânea (Zero Instalação)
276
291
  Experimente ou execute pontualmente sem instalar nada permanentemente:
277
292
  ```bash
@@ -317,14 +332,10 @@ npm start
317
332
 
318
333
  > **Nota:** o servidor não inicia sem pelo menos uma conta configurada (via `.env`/`QWEN_ACCOUNTS`, `npm run login` ou banco de contas).
319
334
 
320
- ### Startup multi-conta
321
-
322
- 1. Prepara as contas em sequência, reutilizando o profile persistente quando ele está autenticado.
323
- 2. Se o profile não tiver uma sessão válida, autentica com as credenciais da conta e salva a sessão em `data/qwen_profiles/<accountId>`.
324
- 3. O servidor sobe após a primeira conta ficar pronta e continua preparando as demais em background.
325
- 4. Com `PLAYWRIGHT_MAX_ACTIVE_CONTEXTS=2` (padrão), 2 contextos ficam abertos após o warmup ({principal + reserva}, cobrindo o failover comum); contextos extras (uso simultâneo ou failover) fecham ao ficar idle. O watchdog RSS fecha contextos idle sob pressão de RAM.
326
- 5. Use `PLAYWRIGHT_PREPARE_ALL_ON_STARTUP=false` para voltar ao modo econômico, preparando as contas adicionais somente quando forem necessárias.
327
-
335
+ 1. O servidor inicia instantaneamente com a **primeira conta pronta** para responder requisições imediatamente.
336
+ 2. Com `PLAYWRIGHT_PREPARE_ALL_ON_STARTUP=false` (padrão econômico), as contas adicionais permanecem em **Standby**, com credenciais validadas no banco, sendo inicializadas apenas sob demanda (failover ou rotação), poupando RAM e CPU.
337
+ 3. Todas as contas ativas compartilham a mesma instância Chromium única, mantendo sessões isoladas via `BrowserContext` e arquivos leves de estado `storage_state.json`.
338
+ 4. O watchdog RSS do sistema monitora a pressão de memória e fecha contextos ociosos automaticamente.
328
339
  Exemplo de log:
329
340
 
330
341
  ```text
@@ -379,7 +390,7 @@ npm run typecheck # tipos
379
390
  | `QWEN_MAX_PERSONALIZATION_BYTES` | `200000` | Teto UTF-8 para personalization por request; acima disso as instruções seguem inline |
380
391
  | `QWEN_CHAT_POOL_SIZE` | `1` | Warm pool de chats por modelo; fica desativado quando personalization por request está ativa |
381
392
  | `QWEN_CHAT_POOL_MODELS` | `qwen3.7-plus` | Modelos aquecidos no warm pool |
382
- | `QWEN_CHAT_MODE` | `thread` | Modo de conversa: `thread` (reutiliza o chat upstream via `parent_id` e envia o delta) ou `temp` (cria um chat temporário `chat_mode:"local"` a cada request e envia o histórico completo). Override por request via header `X-QwenProxy-Chat-Mode: thread/temp` |
393
+ | `QWEN_CHAT_MODE` | `thread` | Modo de conversa: `thread` (reutiliza o chat upstream via `parent_id` e envia o delta), `temp-thread` (chat efêmero `chat_mode:"local"` com contexto contínuo, zero poluição no Qwen Web) ou `temp` (stateless padrão OpenAI, novo chat efêmero por requisição). Override por request via header `X-QwenProxy-Chat-Mode: thread/temp/temp-thread` |
383
394
 
384
395
 
385
396
  ### Playwright / processos
@@ -390,14 +401,14 @@ npm run typecheck # tipos
390
401
  | `PLAYWRIGHT_HEADLESS` | `true` | Browser sem janela |
391
402
  | `PLAYWRIGHT_BROWSER` | `chromium` | `chromium` / `chrome` / `edge` |
392
403
  | `PLAYWRIGHT_INIT_BATCH_SIZE` | `1` | Contas em paralelo no background init |
393
- | `PLAYWRIGHT_PREPARE_ALL_ON_STARTUP` | `true` | Prepara todas as contas no boot (`false` = quando necessárias) |
404
+ | `PLAYWRIGHT_PREPARE_ALL_ON_STARTUP` | `false` | Prepara somente a primeira conta no boot (`false` = modo econômico sob demanda; `true` = aquece todas) |
394
405
  | `PLAYWRIGHT_MAX_ACTIVE_CONTEXTS` | `2` | Contextos idle mantidos quentes ({principal + reserva}); streams ativos nunca são fechados; uso simultâneo abre mais. Contas em cooldown (rate limit) ficam idle e são evictadas |
395
406
  | `PLAYWRIGHT_CONTEXT_CLOSE_TIMEOUT_MS` | `10000` | Timeout de close antes do kill |
396
407
  | `PLAYWRIGHT_IDLE_CONTEXT_TTL_MS` | `60000` | Fecha contextos idle acima do cap (`0` desativa) |
397
408
  | `PLAYWRIGHT_JS_HEAP_MB` | `256` | Cap V8 do Chromium (`--max-old-space-size`) |
398
409
  | `PLAYWRIGHT_LOW_MEMORY_FLAGS` | `true` | Flags de baixa RAM (heap cap, cache mínimo, renderer limit) |
399
410
  | `OSS_MULTIPART_THRESHOLD_MB` | `5` | Acima disso usa multipart OSS; abaixo `putStream` |
400
- | `SESSION_KEEP_ALIVE_ENABLED` | `false` | Keep-alive opt-in (evita Chromes permanentes) |
411
+ | `SESSION_KEEP_ALIVE_ENABLED` | `true` | Simula navegações leves periódicas a cada 3min para manter sessões ativas sem desconectar |
401
412
  | `SESSION_KEEP_ALIVE_INTERVAL_MS` | `180000` | Intervalo do ciclo de keep-alive/cleanup |
402
413
  | `SESSION_KEEP_ALIVE_IDLE_MS` | `120000` | Idle mínimo para keep-alive |
403
414
  | `SESSION_KEEP_ALIVE_NAVIGATION_INTERVAL_MS` | `480000` | Intervalo de navegação leve |
@@ -830,7 +841,8 @@ QwenProxy/
830
841
 
831
842
  | Comando | Descrição |
832
843
  | ------------------- | ----------------------------------------------------------------------- |
833
- | `npm start` | Iniciar o servidor QwenProxy |
844
+ | `npm run tui` | Abrir o dashboard interativo da TUI com proxy embutido |
845
+ | `npm start` | Iniciar apenas o servidor QwenProxy em modo headless |
834
846
  | `npm run sync` | Sincronizar clientes (Claude Code, Codex, OpenCode, OMP) com backup |
835
847
  | `npm run clean` | Limpar caches temporários dos perfis Chromium (~4.5MB por conta) |
836
848
  | `npm run clean:all` | Limpar caches + remover navegadores órfãos e versões antigas no SSD |
@@ -839,7 +851,6 @@ QwenProxy/
839
851
  | `npm run purge` | Limpar chats remotos do Qwen nas contas configuradas |
840
852
  | `npm test` | Executar suíte de testes completa |
841
853
  | `npm run typecheck` | Checagem estrita de tipos do TypeScript |
842
-
843
854
  ---
844
855
 
845
856
  ## Scripts de instalação, início e atualização
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
package/src/api/server.ts CHANGED
@@ -269,6 +269,7 @@ app.get("/health", async (c) => {
269
269
  : undefined,
270
270
  timestamp: Date.now(),
271
271
  readyAccounts: (await import("../core/account-manager.js")).getHeadersReadyAccountIds(),
272
+ activeAccounts: (await import("../services/playwright.js")).getActivePlaywrightAccountIds(),
272
273
  metrics: {
273
274
  cache: await cache?.getStats(),
274
275
  },
@@ -34,7 +34,7 @@ const envSchema = z
34
34
  // temp chat (chat_mode:"local") for every request and sends the full
35
35
  // history inline (OpenAI standard). Temp chats are ephemeral and never
36
36
  // appear in the account's chat list (live-probed).
37
- QWEN_CHAT_MODE: z.enum(["thread", "temp"]).default("thread"),
37
+ QWEN_CHAT_MODE: z.enum(["thread", "temp", "temp-thread"]).default("thread"),
38
38
  PLAYWRIGHT_HEADLESS: z.string().default("true"),
39
39
  PLAYWRIGHT_BROWSER: z
40
40
  .enum(["chromium", "chrome", "edge"])
@@ -379,5 +379,5 @@ export const config = {
379
379
 
380
380
  export type Config = typeof config;
381
381
 
382
- /** Conversation mode: thread-native reuse vs ephemeral temp chat per request. */
383
- export type ChatMode = "thread" | "temp";
382
+ /** Conversation mode: thread-native reuse vs ephemeral temp chat per request vs temp-thread (ephemeral continuous session). */
383
+ export type ChatMode = "thread" | "temp" | "temp-thread";
package/src/index.ts CHANGED
@@ -55,7 +55,11 @@ if (isTui) {
55
55
  const message = error instanceof Error ? error.message : String(error)
56
56
  // Expected configuration errors are already formatted with an emoji and
57
57
  // actionable guidance; print only the message to avoid leaking stack traces.
58
- if (message.includes('[Server]')) {
58
+ if (message.includes('No Qwen accounts configured')) {
59
+ console.error(message)
60
+ console.log('\n👉 Dica: Execute a interface interativa com "qpx" (ou "npm run tui") para gerenciar contas,')
61
+ console.log(' ou configure a variável QWEN_ACCOUNTS no seu arquivo .env.\n')
62
+ } else if (message.includes('[Server]')) {
59
63
  console.error(message)
60
64
  } else {
61
65
  console.error('❌ [Server] Failed to start:', message)
@@ -49,7 +49,12 @@ function formatTimingHeader(timings: Record<string, number>): string {
49
49
  * else silently uses the configured default.
50
50
  */
51
51
  function resolveChatMode(headerValue: string | undefined): ChatMode {
52
- if (headerValue === "thread" || headerValue === "temp") return headerValue;
52
+ if (headerValue === "thread" || headerValue === "temp" || headerValue === "temp-thread") {
53
+ return headerValue;
54
+ }
55
+ if (headerValue === "temp_thread") {
56
+ return "temp-thread";
57
+ }
53
58
  return config.qwen.chatMode;
54
59
  }
55
60
 
@@ -9,7 +9,7 @@ import {
9
9
  isPlaywrightInitialized,
10
10
  closeAllPlaywright,
11
11
  } from "./playwright.ts";
12
-
12
+ import { isAuthMockEnabled } from "./auth-playwright.ts";
13
13
  export interface DeleteChatsResult {
14
14
  attempted: number;
15
15
  succeeded: number;
@@ -17,7 +17,7 @@ export interface DeleteChatsResult {
17
17
  }
18
18
 
19
19
  async function ensurePlaywrightSession(account: QwenAccount): Promise<void> {
20
- if (isPlaywrightInitialized(account.id)) return;
20
+ if (isPlaywrightInitialized(account.id) || isAuthMockEnabled()) return;
21
21
 
22
22
  const credentials = getAccountCredentials(account.id);
23
23
  if (!credentials) {
@@ -33,12 +33,25 @@ async function ensurePlaywrightSession(account: QwenAccount): Promise<void> {
33
33
  );
34
34
  }
35
35
 
36
- async function deleteChatsForAccount(account: QwenAccount): Promise<boolean> {
36
+ export async function deleteChatsForAccount(account: QwenAccount): Promise<boolean> {
37
37
  await ensurePlaywrightSession(account);
38
38
  return deleteAllQwenChats(account.id);
39
39
  }
40
40
 
41
- export async function deleteChatsForConfiguredAccounts(): Promise<DeleteChatsResult> {
41
+ export async function deleteChatsForAccountId(accountId: string): Promise<boolean> {
42
+ const accounts = loadAccounts();
43
+ const account = accounts.find((a) => a.id === accountId);
44
+ if (!account) {
45
+ throw new Error(`Conta ${accountId} não encontrada.`);
46
+ }
47
+ const credentials = getAccountCredentials(account.id);
48
+ if (!credentials) {
49
+ throw new Error(`Credenciais da conta ${account.email} não encontradas.`);
50
+ }
51
+ return deleteChatsForAccount(credentials);
52
+ }
53
+
54
+ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false): Promise<DeleteChatsResult> {
42
55
  // Playwright requests are account-scoped. Use every account persisted in the
43
56
  // database, including accounts created through `npm run login`, instead of
44
57
  // falling back to a global request without an account context.
@@ -64,12 +77,14 @@ export async function deleteChatsForConfiguredAccounts(): Promise<DeleteChatsRes
64
77
  }
65
78
  }
66
79
  } finally {
67
- await closeAllPlaywright().catch((error) => {
68
- console.warn(
69
- `[DeleteChats] Failed to close Playwright sessions:`,
70
- error instanceof Error ? error.message : String(error),
71
- );
72
- });
80
+ if (!keepBrowserOpen) {
81
+ await closeAllPlaywright().catch((error) => {
82
+ console.warn(
83
+ `[DeleteChats] Failed to close Playwright sessions:`,
84
+ error instanceof Error ? error.message : String(error),
85
+ );
86
+ });
87
+ }
73
88
  }
74
89
 
75
90
  return {
@@ -83,8 +83,9 @@ export function buildChatNewBody(
83
83
  project_id: "",
84
84
  timestamp: Date.now(),
85
85
  chat_type: "t2t",
86
- // thread → normal (persisted), temp → local (ephemeral, not listed).
87
- chat_mode: chatMode === "temp" ? "local" : "normal",
86
+ // thread → normal (persisted), temp / temp-thread → local (ephemeral, not listed).
87
+ chat_mode:
88
+ chatMode === "temp" || chatMode === "temp-thread" ? "local" : "normal",
88
89
  };
89
90
  }
90
91
 
@@ -2653,7 +2653,10 @@ async function createQwenStreamInternal(
2653
2653
  chatId: chatSessionId || null,
2654
2654
  parentId: actualParentId ?? "",
2655
2655
  chat_id: chatSessionId || null,
2656
- chat_mode: options?.chatMode === "temp" ? "local" : "normal",
2656
+ chat_mode:
2657
+ options?.chatMode === "temp" || options?.chatMode === "temp-thread"
2658
+ ? "local"
2659
+ : "normal",
2657
2660
  model: model,
2658
2661
  parent_id: actualParentId,
2659
2662
  messages: [
package/src/tui/app.ts CHANGED
@@ -7,7 +7,19 @@ import type { TuiView, ProxyStatusSnapshot } from "./types.ts";
7
7
  import { theme, glyphs, drawBox, stringWidth } from "./theme.ts";
8
8
  import { fetchProxyStatus } from "./proxy-client.ts";
9
9
  import { ServerManager } from "./server-manager.ts";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
10
13
 
14
+ let cachedAppVersion = "v1.0.4";
15
+ try {
16
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
17
+ const pkgPath = path.resolve(currentDir, "../../package.json");
18
+ if (fs.existsSync(pkgPath)) {
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
+ if (pkg.version) cachedAppVersion = `v${pkg.version}`;
21
+ }
22
+ } catch {}
11
23
  import { StatusView } from "./views/status-view.ts";
12
24
  import { ChatView } from "./views/chat-view.ts";
13
25
  import { SyncView } from "./views/sync-view.ts";
@@ -240,7 +252,7 @@ export class TuiApp {
240
252
 
241
253
  const headerContent = [` ${tabsBar}`];
242
254
  const headerBox = drawBox({
243
- title: `QwenProxy v1.0.0 ${statusChip}`,
255
+ title: `QwenProxy ${cachedAppVersion} ${statusChip}`,
244
256
  width: cols,
245
257
  height: 3,
246
258
  borderColor: theme.borderActive,
@@ -2,7 +2,7 @@
2
2
  * QwenProxy TUI - Proxy Data Provider & Live State Client
3
3
  */
4
4
 
5
- import { config } from "../core/config.ts";
5
+ import { config, type ChatMode } from "../core/config.ts";
6
6
  import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
7
7
  import {
8
8
  getAccountCooldownInfo,
@@ -10,6 +10,7 @@ import {
10
10
  clearAccountCooldown,
11
11
  isAccountHeadersReady,
12
12
  } from "../core/account-manager.ts";
13
+ import { isPlaywrightInitialized } from "../services/playwright.ts";
13
14
  import { getAccountConcurrencySnapshot } from "../core/account-concurrency.ts";
14
15
  import { getRssUsageSnapshot } from "../core/memory-usage.ts";
15
16
  import type { ProxyStatusSnapshot } from "./types.ts";
@@ -46,12 +47,14 @@ let cachedAccounts: Array<{
46
47
  onCooldown: boolean;
47
48
  remainingCooldownMs: number;
48
49
  headersReady: boolean;
50
+ isInitialized: boolean;
49
51
  }> = [];
50
52
  let lastAccountsFetch = 0;
51
53
  let isHealthCheckPending = false;
52
54
  let lastOnlineState = false;
53
55
  let lastOverallStatus = "offline";
54
56
  let lastServerReadyAccounts: Set<string> | null = null;
57
+ let lastServerActiveAccounts: Set<string> | null = null;
55
58
  export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
56
59
  const port = config.server?.port || 7936;
57
60
  const configuredHost = config.server?.host;
@@ -73,9 +76,13 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
73
76
  if (Array.isArray(data.readyAccounts)) {
74
77
  lastServerReadyAccounts = new Set(data.readyAccounts);
75
78
  }
79
+ if (Array.isArray(data.activeAccounts)) {
80
+ lastServerActiveAccounts = new Set(data.activeAccounts);
81
+ }
76
82
  } else {
77
83
  lastOnlineState = false;
78
84
  lastServerReadyAccounts = null;
85
+ lastServerActiveAccounts = null;
79
86
  }
80
87
  })
81
88
  .catch(() => {
@@ -104,6 +111,9 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
104
111
  const headersReady = lastServerReadyAccounts !== null
105
112
  ? lastServerReadyAccounts.has(acc.id)
106
113
  : isAccountHeadersReady(acc.id);
114
+ const isInitialized = lastServerActiveAccounts !== null
115
+ ? lastServerActiveAccounts.has(acc.id)
116
+ : isPlaywrightInitialized(acc.id);
107
117
  return {
108
118
  id: acc.id,
109
119
  emailOrName: maskAccountIdentifier(acc.email || acc.id),
@@ -112,10 +122,10 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
112
122
  onCooldown,
113
123
  remainingCooldownMs,
114
124
  headersReady,
125
+ isInitialized,
115
126
  };
116
127
  });
117
128
  }
118
-
119
129
  const accounts = cachedAccounts;
120
130
  const online = lastOnlineState;
121
131
  const overallStatus = lastOverallStatus;
@@ -166,6 +176,7 @@ export interface StreamChatOptions {
166
176
  model: string;
167
177
  reasoning_effort?: "low" | "medium" | "high";
168
178
  messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
179
+ chatMode?: ChatMode;
169
180
  onToken: (text: string) => void;
170
181
  onReasoning?: (text: string) => void;
171
182
  signal?: AbortSignal;
@@ -187,11 +198,13 @@ export async function streamChatCompletions(
187
198
 
188
199
  let resp: Response;
189
200
  try {
201
+ const chatMode = options.chatMode ?? "temp-thread";
190
202
  resp = await fetch(`http://${host}:${port}/v1/chat/completions`, {
191
203
  method: "POST",
192
204
  headers: {
193
205
  "Content-Type": "application/json",
194
206
  Authorization: `Bearer ${apiKey}`,
207
+ "x-qwenproxy-chat-mode": chatMode,
195
208
  },
196
209
  body: JSON.stringify({
197
210
  model: options.model,
package/src/tui/types.ts CHANGED
@@ -29,5 +29,6 @@ export interface ProxyStatusSnapshot {
29
29
  onCooldown: boolean;
30
30
  remainingCooldownMs: number;
31
31
  headersReady: boolean;
32
+ isInitialized?: boolean;
32
33
  }>;
33
34
  }
@@ -33,6 +33,16 @@ export class AccountsView implements TuiView {
33
33
  private modalHoveredField: "email" | "password" | "save" | "cancel" | null = null;
34
34
  private lastModalLeftPad = 0;
35
35
  private lastLeftW = 46;
36
+ private confirmDialog: {
37
+ type: "remove_account" | "delete_account_chats" | "delete_all_chats";
38
+ title: string;
39
+ message: string;
40
+ detail: string;
41
+ onConfirm: () => Promise<void>;
42
+ } | null = null;
43
+ private confirmDialogHovered: "confirm" | "cancel" | null = null;
44
+ private lastConfirmModalLeftPad = 0;
45
+ private lastConfirmModalStartRow = 0;
36
46
  constructor() {
37
47
  this.refresh();
38
48
  }
@@ -42,9 +52,15 @@ export class AccountsView implements TuiView {
42
52
  }
43
53
 
44
54
  public isCapturingText(): boolean {
45
- return this.isAddModalOpen;
55
+ return this.isAddModalOpen || this.confirmDialog !== null;
46
56
  }
47
57
  public getShortcuts(): Array<{ key: string; label: string }> {
58
+ if (this.confirmDialog) {
59
+ return [
60
+ { key: "S / Enter", label: "Confirmar" },
61
+ { key: "N / Esc", label: "Cancelar" },
62
+ ];
63
+ }
48
64
  if (this.isAddModalOpen) {
49
65
  return [
50
66
  { key: "↑↓/Mouse", label: "Alternar" },
@@ -55,6 +71,8 @@ export class AccountsView implements TuiView {
55
71
  return [
56
72
  { key: "a", label: "Adicionar Conta" },
57
73
  { key: "d", label: "Remover Conta" },
74
+ { key: "x", label: "Limpar Chats" },
75
+ { key: "l", label: "Limpar Todos Chats" },
58
76
  { key: "c", label: "Zerar Cooldown" },
59
77
  { key: "z", label: "Zerar Todas" },
60
78
  ];
@@ -125,6 +143,63 @@ export class AccountsView implements TuiView {
125
143
  }
126
144
  }
127
145
  public async handleKey(key: KeyEvent): Promise<boolean | void> {
146
+ // 0. Confirm Dialog Active
147
+ if (this.confirmDialog) {
148
+ if (key.name === "s" || key.name === "S" || key.name === "enter" || key.name === "return") {
149
+ const dialog = this.confirmDialog;
150
+ this.confirmDialog = null;
151
+ this.confirmDialogHovered = null;
152
+ await dialog.onConfirm();
153
+ return true;
154
+ }
155
+ if (key.name === "escape" || key.name === "n" || key.name === "N") {
156
+ this.confirmDialog = null;
157
+ this.confirmDialogHovered = null;
158
+ this.setStatusMessage(theme.muted("Ação cancelada"));
159
+ return true;
160
+ }
161
+ if (key.name === "hover" && key.mouse) {
162
+ const { row, col } = key.mouse;
163
+ const btnRow = this.lastConfirmModalStartRow + 4;
164
+ if (row === btnRow) {
165
+ const startCol = this.lastConfirmModalLeftPad + 2;
166
+ if (col >= startCol && col <= startCol + 25) {
167
+ this.confirmDialogHovered = "confirm";
168
+ return true;
169
+ }
170
+ if (col >= startCol + 26 && col <= startCol + 50) {
171
+ this.confirmDialogHovered = "cancel";
172
+ return true;
173
+ }
174
+ }
175
+ if (this.confirmDialogHovered !== null) {
176
+ this.confirmDialogHovered = null;
177
+ return true;
178
+ }
179
+ }
180
+ if (key.name === "click" && key.mouse) {
181
+ const { row, col } = key.mouse;
182
+ const btnRow = this.lastConfirmModalStartRow + 4;
183
+ if (row === btnRow) {
184
+ const startCol = this.lastConfirmModalLeftPad + 2;
185
+ if (col >= startCol && col <= startCol + 25) {
186
+ const dialog = this.confirmDialog;
187
+ this.confirmDialog = null;
188
+ this.confirmDialogHovered = null;
189
+ await dialog.onConfirm();
190
+ return true;
191
+ }
192
+ if (col >= startCol + 26 && col <= startCol + 50) {
193
+ this.confirmDialog = null;
194
+ this.confirmDialogHovered = null;
195
+ this.setStatusMessage(theme.muted("Ação cancelada"));
196
+ return true;
197
+ }
198
+ }
199
+ }
200
+ return true;
201
+ }
202
+
128
203
  // 1. Add Account Modal Active
129
204
  if (this.isAddModalOpen) {
130
205
  if (key.name === "escape") {
@@ -335,22 +410,81 @@ export class AccountsView implements TuiView {
335
410
  return true;
336
411
  }
337
412
 
338
- // Delete selected account with 'd' or 'D'
413
+ // Delete selected account with 'd' or 'D' (requires confirmation)
339
414
  if ((key.name === "d" || key.name === "D") && !key.ctrl) {
340
415
  const selected = accounts[this.selectedIndex];
341
416
  if (!selected) {
342
417
  this.setStatusMessage(theme.yellow("[!] Nenhuma conta selecionada para remover"));
343
418
  return true;
344
419
  }
345
- removeAccount(selected.id);
346
- void (async () => {
347
- try {
348
- const { closePlaywrightForAccount } = await import("../../services/playwright.ts");
349
- await closePlaywrightForAccount(selected.id);
350
- } catch {}
351
- })();
352
- await this.refresh();
353
- this.setStatusMessage(theme.green(`✓ Conta ${selected.emailOrName} removida com sucesso`));
420
+ this.confirmDialog = {
421
+ type: "remove_account",
422
+ title: "⚠️ Confirmar Remoção de Conta",
423
+ message: `Deseja remover a conta ${selected.emailOrName}?`,
424
+ detail: "A conta será excluída do banco de dados e sua sessão encerrada.",
425
+ onConfirm: async () => {
426
+ removeAccount(selected.id);
427
+ try {
428
+ const { closePlaywrightForAccount } = await import("../../services/playwright.ts");
429
+ await closePlaywrightForAccount(selected.id);
430
+ } catch {}
431
+ await this.refresh();
432
+ this.setStatusMessage(theme.green(`✓ Conta ${selected.emailOrName} removida com sucesso`));
433
+ },
434
+ };
435
+ return true;
436
+ }
437
+
438
+ // Delete chats of selected account with 'x' or 'X' (requires confirmation)
439
+ if ((key.name === "x" || key.name === "X") && !key.ctrl) {
440
+ const selected = accounts[this.selectedIndex];
441
+ if (!selected) {
442
+ this.setStatusMessage(theme.yellow("[!] Nenhuma conta selecionada"));
443
+ return true;
444
+ }
445
+ this.confirmDialog = {
446
+ type: "delete_account_chats",
447
+ title: "⚠️ Apagar Chats Remotos no Qwen",
448
+ message: `Apagar TODOS os chats no Qwen da conta ${selected.emailOrName}?`,
449
+ detail: "Esta ação é irreversível e limpará todas as conversas em chat.qwen.ai.",
450
+ onConfirm: async () => {
451
+ this.setStatusMessage(theme.yellow(`⏳ Apagando chats no Qwen para ${selected.emailOrName}...`));
452
+ try {
453
+ const { deleteChatsForAccountId } = await import("../../services/chat-cleanup.ts");
454
+ await deleteChatsForAccountId(selected.id);
455
+ await this.refresh();
456
+ this.setStatusMessage(theme.green(`✓ Todos os chats de ${selected.emailOrName} foram apagados no Qwen!`));
457
+ } catch (err: any) {
458
+ this.setStatusMessage(theme.red(`✗ Falha ao apagar chats: ${err?.message || String(err)}`));
459
+ }
460
+ },
461
+ };
462
+ return true;
463
+ }
464
+
465
+ // Delete chats of all accounts with 'l' or 'L' (requires confirmation)
466
+ if ((key.name === "l" || key.name === "L") && !key.ctrl) {
467
+ if (accounts.length === 0) {
468
+ this.setStatusMessage(theme.yellow("[!] Nenhuma conta configurada"));
469
+ return true;
470
+ }
471
+ this.confirmDialog = {
472
+ type: "delete_all_chats",
473
+ title: "⚠️ Apagar Chats de TODAS as Contas",
474
+ message: `Apagar TODOS os chats remotos de TODAS as ${accounts.length} contas no Qwen?`,
475
+ detail: "Esta ação é irreversível e limpará o histórico no chat.qwen.ai.",
476
+ onConfirm: async () => {
477
+ this.setStatusMessage(theme.yellow(`⏳ Apagando chats no Qwen de todas as contas...`));
478
+ try {
479
+ const { deleteChatsForConfiguredAccounts } = await import("../../services/chat-cleanup.ts");
480
+ const res = await deleteChatsForConfiguredAccounts(true);
481
+ await this.refresh();
482
+ this.setStatusMessage(theme.green(`✓ Chats apagados no Qwen: ${res.succeeded}/${res.attempted} contas limpas!`));
483
+ } catch (err: any) {
484
+ this.setStatusMessage(theme.red(`✗ Falha ao apagar chats: ${err?.message || String(err)}`));
485
+ }
486
+ },
487
+ };
354
488
  return true;
355
489
  }
356
490
 
@@ -371,9 +505,9 @@ export class AccountsView implements TuiView {
371
505
  return true;
372
506
  }
373
507
 
374
- // Right panel action buttons hover (rows 15 to 18)
508
+ // Right panel action buttons hover (rows 15 to 20)
375
509
  if (col >= leftW) {
376
- if (row >= 15 && row <= 18) {
510
+ if (row >= 15 && row <= 20) {
377
511
  if (this.hoveredActionRow !== row) {
378
512
  this.hoveredActionRow = row;
379
513
  return true;
@@ -387,7 +521,6 @@ export class AccountsView implements TuiView {
387
521
  return true;
388
522
  }
389
523
  }
390
-
391
524
  // Mouse click on account rows or action buttons
392
525
  if (key.name === "click" && key.mouse) {
393
526
  const { row, col } = key.mouse;
@@ -416,9 +549,16 @@ export class AccountsView implements TuiView {
416
549
  await this.handleKey({ name: "z", ctrl: false, shift: false, meta: false });
417
550
  return true;
418
551
  }
552
+ if (row === 19) {
553
+ await this.handleKey({ name: "x", ctrl: false, shift: false, meta: false });
554
+ return true;
555
+ }
556
+ if (row === 20) {
557
+ await this.handleKey({ name: "l", ctrl: false, shift: false, meta: false });
558
+ return true;
559
+ }
419
560
  }
420
561
  }
421
- // Navigate accounts list (Mouse wheel or Up/Down keys)
422
562
  if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
423
563
  if (accounts.length > 0) {
424
564
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
@@ -469,6 +609,9 @@ export class AccountsView implements TuiView {
469
609
  this.lastLeftW = leftW;
470
610
  const rightW = Math.max(30, width - leftW - 1);
471
611
 
612
+ if (snapshot) {
613
+ this.statusData = snapshot;
614
+ }
472
615
  const data = snapshot || this.statusData;
473
616
  const accounts = data?.accounts || [];
474
617
  const selected = accounts[this.selectedIndex];
@@ -497,7 +640,9 @@ export class AccountsView implements TuiView {
497
640
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
498
641
  status = theme.yellow(`⚠ ${mins}m cd `);
499
642
  } else if (!acc.headersReady) {
500
- status = theme.yellow(`◐ Aquecendo...`);
643
+ status = acc.isInitialized
644
+ ? theme.yellow(`◐ Aquecendo...`)
645
+ : theme.muted(`○ Standby `);
501
646
  }
502
647
 
503
648
  const line = `${pointer}${num}${name}${status}`;
@@ -550,7 +695,9 @@ export class AccountsView implements TuiView {
550
695
 
551
696
  const hStatus = selected.headersReady
552
697
  ? theme.green(`${glyphs.check} Capturados`)
553
- : theme.muted(`${glyphs.circle} Pendente`);
698
+ : selected.isInitialized
699
+ ? theme.yellow(`◐ Aquecendo...`)
700
+ : theme.muted(`${glyphs.circle} Standby (Sob Demanda)`);
554
701
  rightContent.push(` ${theme.bold("Headers:")} ${hStatus}`);
555
702
  rightContent.push("");
556
703
  rightContent.push(` ${theme.dim("─────────────────────────────────")}`);
@@ -558,8 +705,9 @@ export class AccountsView implements TuiView {
558
705
  rightContent.push(` ${this.hoveredActionRow === 16 ? theme.bgHover(` ${theme.red("[ D ] Remover Conta")} `) : `${theme.red("[ D ]")} Remover Conta`}`);
559
706
  rightContent.push(` ${this.hoveredActionRow === 17 ? theme.bgHover(` ${theme.yellow("[ C ] Zerar Cooldown")} `) : `${theme.yellow("[ C ]")} Zerar Cooldown`}`);
560
707
  rightContent.push(` ${this.hoveredActionRow === 18 ? theme.bgHover(` ${theme.green("[ Z ] Zerar Todas")} `) : `${theme.green("[ Z ]")} Zerar Todas`}`);
708
+ rightContent.push(` ${this.hoveredActionRow === 19 ? theme.bgHover(` ${theme.peach("[ X ] Limpar Chats (Conta)")} `) : `${theme.peach("[ X ]")} Limpar Chats (Conta)`}`);
709
+ rightContent.push(` ${this.hoveredActionRow === 20 ? theme.bgHover(` ${theme.red("[ L ] Limpar Todos os Chats")} `) : `${theme.red("[ L ]")} Limpar Todos os Chats`}`);
561
710
  }
562
-
563
711
  const rightBox = drawBox({
564
712
  title: "Inspeção de Conta",
565
713
  width: rightW,
@@ -651,6 +799,39 @@ export class AccountsView implements TuiView {
651
799
  return modalBox.map((line) => padStr + line);
652
800
  }
653
801
 
802
+ if (this.confirmDialog) {
803
+ const modalW = Math.min(width - 4, 66);
804
+ this.lastConfirmModalLeftPad = Math.max(0, Math.floor((width - modalW) / 2));
805
+ const confirmBtn =
806
+ this.confirmDialogHovered === "confirm"
807
+ ? theme.bgHover(theme.red(" [ S / Enter ] Sim, Confirmar "))
808
+ : theme.red("[ S / Enter ] Sim, Confirmar");
809
+ const cancelBtn =
810
+ this.confirmDialogHovered === "cancel"
811
+ ? theme.bgHover(theme.green(" [ N / Esc ] Cancelar "))
812
+ : theme.green("[ N / Esc ] Cancelar");
813
+
814
+ const modalContent = [
815
+ "",
816
+ ` ${theme.bold(this.confirmDialog.message)}`,
817
+ ` ${theme.muted(this.confirmDialog.detail)}`,
818
+ "",
819
+ ` ${confirmBtn} ${cancelBtn}`,
820
+ ];
821
+
822
+ const modalBox = drawBox({
823
+ title: this.confirmDialog.title,
824
+ width: modalW,
825
+ height: Math.min(contentH, 8),
826
+ borderColor: theme.red,
827
+ titleColor: theme.red,
828
+ content: modalContent,
829
+ });
830
+
831
+ const padStr = " ".repeat(this.lastConfirmModalLeftPad);
832
+ return modalBox.map((line) => padStr + line);
833
+ }
834
+
654
835
  return mergedLines;
655
836
  }
656
837
  }
@@ -175,7 +175,9 @@ export class StatusView implements TuiView {
175
175
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
176
176
  status = theme.yellow(`⚠ Cooldown ${mins}m`);
177
177
  } else if (!acc.headersReady) {
178
- status = theme.yellow(`◐ Aquecendo...`);
178
+ status = acc.isInitialized
179
+ ? theme.yellow(`◐ Aquecendo...`)
180
+ : theme.muted(`○ Standby`);
179
181
  }
180
182
  rightContent.push(` ${num} ${name} ${status}`);
181
183
  });
@@ -34,8 +34,7 @@ export class StorageView implements TuiView {
34
34
  private isScanning = false;
35
35
 
36
36
  private addLog(message: string): void {
37
- const time = new Date().toTimeString().slice(0, 8);
38
- this.actionLogs.push(`${theme.dim(`[${time}]`)} ${message}`);
37
+ this.actionLogs.push(message);
39
38
  if (this.actionLogs.length > 50) {
40
39
  this.actionLogs.shift();
41
40
  }
@@ -46,15 +45,35 @@ export class StorageView implements TuiView {
46
45
  }
47
46
  private hoveredActionRow: number | null = null;
48
47
  private lastLeftW = 48;
48
+ private confirmDialog: {
49
+ title: string;
50
+ message: string;
51
+ detail: string;
52
+ onConfirm: () => Promise<void>;
53
+ } | null = null;
54
+ private confirmDialogHovered: "confirm" | "cancel" | null = null;
55
+ private lastConfirmModalLeftPad = 0;
56
+ private lastConfirmModalStartRow = 0;
57
+
58
+ public isCapturingText(): boolean {
59
+ return this.confirmDialog !== null;
60
+ }
61
+
49
62
  public getShortcuts(): Array<{ key: string; label: string }> {
63
+ if (this.confirmDialog) {
64
+ return [
65
+ { key: "S / Enter", label: "Confirmar" },
66
+ { key: "N / Esc", label: "Cancelar" },
67
+ ];
68
+ }
50
69
  return [
51
70
  { key: "p", label: "Podar Caches" },
52
71
  { key: "b", label: "Limpar Navegadores" },
53
72
  { key: "z", label: "Zerar Cooldowns" },
73
+ { key: "l", label: "Limpar Chats Qwen" },
54
74
  { key: "r", label: "Atualizar Disco" },
55
75
  ];
56
76
  }
57
-
58
77
  public async refresh(): Promise<void> {
59
78
  if (this.isScanning) return;
60
79
  this.isScanning = true;
@@ -102,11 +121,68 @@ export class StorageView implements TuiView {
102
121
  }
103
122
 
104
123
  public async handleKey(key: KeyEvent): Promise<boolean | void> {
124
+ // 0. Confirm Dialog Active
125
+ if (this.confirmDialog) {
126
+ if (key.name === "s" || key.name === "S" || key.name === "enter" || key.name === "return") {
127
+ const dialog = this.confirmDialog;
128
+ this.confirmDialog = null;
129
+ this.confirmDialogHovered = null;
130
+ await dialog.onConfirm();
131
+ return true;
132
+ }
133
+ if (key.name === "escape" || key.name === "n" || key.name === "N") {
134
+ this.confirmDialog = null;
135
+ this.confirmDialogHovered = null;
136
+ this.addLog(theme.muted("Ação cancelada"));
137
+ return true;
138
+ }
139
+ if (key.name === "hover" && key.mouse) {
140
+ const { row, col } = key.mouse;
141
+ const btnRow = this.lastConfirmModalStartRow + 4;
142
+ if (row === btnRow) {
143
+ const startCol = this.lastConfirmModalLeftPad + 2;
144
+ if (col >= startCol && col <= startCol + 25) {
145
+ this.confirmDialogHovered = "confirm";
146
+ return true;
147
+ }
148
+ if (col >= startCol + 26 && col <= startCol + 50) {
149
+ this.confirmDialogHovered = "cancel";
150
+ return true;
151
+ }
152
+ }
153
+ if (this.confirmDialogHovered !== null) {
154
+ this.confirmDialogHovered = null;
155
+ return true;
156
+ }
157
+ }
158
+ if (key.name === "click" && key.mouse) {
159
+ const { row, col } = key.mouse;
160
+ const btnRow = this.lastConfirmModalStartRow + 4;
161
+ if (row === btnRow) {
162
+ const startCol = this.lastConfirmModalLeftPad + 2;
163
+ if (col >= startCol && col <= startCol + 25) {
164
+ const dialog = this.confirmDialog;
165
+ this.confirmDialog = null;
166
+ this.confirmDialogHovered = null;
167
+ await dialog.onConfirm();
168
+ return true;
169
+ }
170
+ if (col >= startCol + 26 && col <= startCol + 50) {
171
+ this.confirmDialog = null;
172
+ this.confirmDialogHovered = null;
173
+ this.addLog(theme.muted("Ação cancelada"));
174
+ return true;
175
+ }
176
+ }
177
+ }
178
+ return true;
179
+ }
180
+
105
181
  // Mouse hover over quick actions
106
182
  if (key.name === "hover" && key.mouse) {
107
183
  const { row, col } = key.mouse;
108
184
  const leftW = this.lastLeftW || 48;
109
- if (col >= 2 && col <= leftW - 1 && row >= 13 && row <= 16) {
185
+ if (col >= 2 && col <= leftW - 1 && row >= 13 && row <= 17) {
110
186
  if (this.hoveredActionRow !== row) {
111
187
  this.hoveredActionRow = row;
112
188
  return true;
@@ -135,12 +211,15 @@ export class StorageView implements TuiView {
135
211
  return true;
136
212
  }
137
213
  if (row === 16) {
214
+ this.handleKey({ name: "l", ctrl: false, shift: false, meta: false });
215
+ return true;
216
+ }
217
+ if (row === 17) {
138
218
  this.handleKey({ name: "r", ctrl: false, shift: false, meta: false });
139
219
  return true;
140
220
  }
141
221
  }
142
222
  }
143
-
144
223
  // Reset cooldowns with 'z'
145
224
  if ((key.name === "z" || key.name === "Z") && !key.ctrl) {
146
225
  const cleared = resetAllCooldowns();
@@ -187,7 +266,7 @@ export class StorageView implements TuiView {
187
266
  } else {
188
267
  this.addLog(
189
268
  theme.green(
190
- `✓ Navegadores verificados: nenhum navegador antigo encontrado (SSD já otimizado)`,
269
+ `✓ Navegadores verificados: nenhum navegador antigo encontrado`,
191
270
  ),
192
271
  );
193
272
  }
@@ -199,6 +278,29 @@ export class StorageView implements TuiView {
199
278
  }
200
279
  return true;
201
280
  }
281
+ // Delete all remote chats with 'l' or 'L' (requires confirmation)
282
+ if ((key.name === "l" || key.name === "L") && !key.ctrl) {
283
+ this.confirmDialog = {
284
+ title: "⚠️ Confirmar Exclusão de Chats Remotos",
285
+ message: "Apagar TODOS os chats remotos de TODAS as contas no Qwen?",
286
+ detail: "Esta ação apagará permanentemente todas as conversas em chat.qwen.ai.",
287
+ onConfirm: async () => {
288
+ this.addLog(theme.yellow("⏳ Apagando chats no Qwen de todas as contas..."));
289
+ try {
290
+ const { deleteChatsForConfiguredAccounts } = await import("../../services/chat-cleanup.ts");
291
+ const res = await deleteChatsForConfiguredAccounts(true);
292
+ this.addLog(
293
+ theme.green(
294
+ `✓ Todos os chats remotos foram apagados no Qwen (${res.succeeded}/${res.attempted} contas)`,
295
+ ),
296
+ );
297
+ } catch (err: any) {
298
+ this.addLog(theme.red(`✗ Falha ao apagar chats: ${err?.message || String(err)}`));
299
+ }
300
+ },
301
+ };
302
+ return true;
303
+ }
202
304
  }
203
305
  public render(width: number, height: number): string[] {
204
306
  const contentH = Math.max(12, height);
@@ -233,7 +335,8 @@ export class StorageView implements TuiView {
233
335
  ` ${this.hoveredActionRow === 13 ? theme.bgHover(` ${theme.cyan("[ P ] Podar Caches")} `) : `${theme.cyan("[ P ]")} Podar Caches`}`,
234
336
  ` ${this.hoveredActionRow === 14 ? theme.bgHover(` ${theme.yellow("[ B ] Limpar Navegadores")} `) : `${theme.yellow("[ B ]")} Limpar Navegadores`}`,
235
337
  ` ${this.hoveredActionRow === 15 ? theme.bgHover(` ${theme.green("[ Z ] Zerar Todos os Cooldowns")} `) : `${theme.green("[ Z ]")} Zerar Todos os Cooldowns`}`,
236
- ` ${this.hoveredActionRow === 16 ? theme.bgHover(` ${theme.muted("[ R ] Atualizar Disco")} `) : `${theme.muted("[ R ]")} Atualizar Disco`}`,
338
+ ` ${this.hoveredActionRow === 16 ? theme.bgHover(` ${theme.red("[ L ] Limpar Todos os Chats (Qwen)")} `) : `${theme.red("[ L ]")} Limpar Todos os Chats (Qwen)`}`,
339
+ ` ${this.hoveredActionRow === 17 ? theme.bgHover(` ${theme.muted("[ R ] Atualizar Disco")} `) : `${theme.muted("[ R ]")} Atualizar Disco`}`,
237
340
  "",
238
341
  ];
239
342
 
@@ -299,6 +402,38 @@ export class StorageView implements TuiView {
299
402
  mergedLines.push(leftRow + " " + rightRow);
300
403
  }
301
404
 
405
+ if (this.confirmDialog) {
406
+ const modalW = Math.min(width - 4, 66);
407
+ this.lastConfirmModalLeftPad = Math.max(0, Math.floor((width - modalW) / 2));
408
+ const confirmBtn =
409
+ this.confirmDialogHovered === "confirm"
410
+ ? theme.bgHover(theme.red(" [ S / Enter ] Sim, Confirmar "))
411
+ : theme.red("[ S / Enter ] Sim, Confirmar");
412
+ const cancelBtn =
413
+ this.confirmDialogHovered === "cancel"
414
+ ? theme.bgHover(theme.green(" [ N / Esc ] Cancelar "))
415
+ : theme.green("[ N / Esc ] Cancelar");
416
+
417
+ const modalContent = [
418
+ "",
419
+ ` ${theme.bold(this.confirmDialog.message)}`,
420
+ ` ${theme.muted(this.confirmDialog.detail)}`,
421
+ "",
422
+ ` ${confirmBtn} ${cancelBtn}`,
423
+ ];
424
+
425
+ const modalBox = drawBox({
426
+ title: this.confirmDialog.title,
427
+ width: modalW,
428
+ height: Math.min(contentH, 8),
429
+ borderColor: theme.red,
430
+ titleColor: theme.red,
431
+ content: modalContent,
432
+ });
433
+
434
+ const padStr = " ".repeat(this.lastConfirmModalLeftPad);
435
+ return modalBox.map((line) => padStr + line);
436
+ }
302
437
  return mergedLines;
303
438
  }
304
439
  }