qwenproxy-cli 1.0.0
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/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QwenProxy TUI - Interactive Chat Tester View (Tab 2)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TuiView, ProxyStatusSnapshot } from "../types.ts";
|
|
6
|
+
import type { KeyEvent } from "../screen.ts";
|
|
7
|
+
import { theme, glyphs, drawBox, stringWidth, truncate, stripAnsi, pad, wrapContentLine } from "../theme.ts";
|
|
8
|
+
import { streamChatCompletions, fetchLiveModels } from "../proxy-client.ts";
|
|
9
|
+
import { ServerManager } from "../server-manager.ts";
|
|
10
|
+
import { formatMarkdown, formatReasoning } from "../markdown.ts";
|
|
11
|
+
|
|
12
|
+
interface ChatMessage {
|
|
13
|
+
role: "user" | "assistant";
|
|
14
|
+
content: string;
|
|
15
|
+
reasoning?: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
ttfbMs?: number;
|
|
18
|
+
totalTimeMs?: number;
|
|
19
|
+
cachedContentLines?: string[];
|
|
20
|
+
cachedReasoningBox?: string[];
|
|
21
|
+
cachedWidth?: number;
|
|
22
|
+
}
|
|
23
|
+
export function classifyModel(modelId: string): { badge: string; category: string } {
|
|
24
|
+
const lower = modelId.toLowerCase();
|
|
25
|
+
if (
|
|
26
|
+
lower.includes("image") ||
|
|
27
|
+
lower.startsWith("z-image") ||
|
|
28
|
+
lower.includes("t2i") ||
|
|
29
|
+
lower.includes("i2i")
|
|
30
|
+
) {
|
|
31
|
+
return { badge: theme.lavender("[Imagem]"), category: "Geração de Imagem" };
|
|
32
|
+
}
|
|
33
|
+
if (lower.includes("video") || lower.includes("t2v") || lower.includes("i2v")) {
|
|
34
|
+
return { badge: theme.peach("[Vídeo] "), category: "Geração de Vídeo" };
|
|
35
|
+
}
|
|
36
|
+
return { badge: theme.cyan("[Texto] "), category: "Texto & Raciocínio" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ChatView implements TuiView {
|
|
40
|
+
public readonly id = "chat";
|
|
41
|
+
public readonly title = "Chat";
|
|
42
|
+
public readonly tabNumber = 2;
|
|
43
|
+
|
|
44
|
+
private availableModels = [
|
|
45
|
+
"qwen3.8-max",
|
|
46
|
+
"qwen3.7-plus",
|
|
47
|
+
"qwen3.7-max",
|
|
48
|
+
"z-image-turbo",
|
|
49
|
+
"qwen-image-3.0-pro",
|
|
50
|
+
"wan3.0-video",
|
|
51
|
+
];
|
|
52
|
+
private selectedModelIndex = 0;
|
|
53
|
+
private messages: ChatMessage[] = [];
|
|
54
|
+
private inputBuffer = "";
|
|
55
|
+
private cursorPos = 0;
|
|
56
|
+
private scrollOffset = 0; // 0 = follow bottom (newest messages)
|
|
57
|
+
private isModelModalOpen = false;
|
|
58
|
+
private modalSelectedIndex = 0;
|
|
59
|
+
private availableEfforts: Array<{
|
|
60
|
+
id: "high" | "medium" | "low";
|
|
61
|
+
label: string;
|
|
62
|
+
desc: string;
|
|
63
|
+
badge: string;
|
|
64
|
+
}> = [
|
|
65
|
+
{
|
|
66
|
+
id: "high",
|
|
67
|
+
label: "High (Thinking)",
|
|
68
|
+
desc: "Raciocínio profundo ativado (ideal para código)",
|
|
69
|
+
badge: theme.green("[High]"),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "medium",
|
|
73
|
+
label: "Medium (Auto)",
|
|
74
|
+
desc: "Raciocínio dinâmico (Qwen decide quando pensar)",
|
|
75
|
+
badge: theme.yellow("[Medium]"),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "low",
|
|
79
|
+
label: "Low (Fast)",
|
|
80
|
+
desc: "Raciocínio desativado (respostas ultrarrápidas)",
|
|
81
|
+
badge: theme.cyan("[Low]"),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
private selectedEffort: "high" | "medium" | "low" = "high";
|
|
85
|
+
private isEffortModalOpen = false;
|
|
86
|
+
private effortSelectedIndex = 0;
|
|
87
|
+
private isGenerating = false;
|
|
88
|
+
private currentAbortController: AbortController | null = null;
|
|
89
|
+
private statusNote = "";
|
|
90
|
+
private lastSnapshot: ProxyStatusSnapshot | null = null;
|
|
91
|
+
private onNeedsRender?: () => void;
|
|
92
|
+
private renderThrottleTimer: NodeJS.Timeout | null = null;
|
|
93
|
+
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
94
|
+
private spinnerIndex = 0;
|
|
95
|
+
private spinnerInterval: NodeJS.Timeout | null = null;
|
|
96
|
+
|
|
97
|
+
private scheduleRender(): void {
|
|
98
|
+
if (this.renderThrottleTimer) return;
|
|
99
|
+
this.renderThrottleTimer = setTimeout(() => {
|
|
100
|
+
this.renderThrottleTimer = null;
|
|
101
|
+
this.onNeedsRender?.();
|
|
102
|
+
}, 40);
|
|
103
|
+
}
|
|
104
|
+
private flushRender(): void {
|
|
105
|
+
if (this.renderThrottleTimer) {
|
|
106
|
+
clearTimeout(this.renderThrottleTimer);
|
|
107
|
+
this.renderThrottleTimer = null;
|
|
108
|
+
}
|
|
109
|
+
this.onNeedsRender?.();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
constructor(onNeedsRender?: () => void) {
|
|
113
|
+
this.onNeedsRender = onNeedsRender;
|
|
114
|
+
void this.refreshModels();
|
|
115
|
+
}
|
|
116
|
+
public onActivate(): void {
|
|
117
|
+
void this.refreshModels();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public isModalOpen(): boolean {
|
|
121
|
+
return this.isModelModalOpen || this.isEffortModalOpen;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public async refreshModels(): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
const live = await fetchLiveModels();
|
|
127
|
+
if (live.length > 0) {
|
|
128
|
+
const current = this.availableModels[this.selectedModelIndex];
|
|
129
|
+
this.availableModels = live;
|
|
130
|
+
const foundIdx = this.availableModels.indexOf(current);
|
|
131
|
+
this.selectedModelIndex = foundIdx !== -1 ? foundIdx : 0;
|
|
132
|
+
this.onNeedsRender?.();
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
public getShortcuts(): Array<{ key: string; label: string }> {
|
|
137
|
+
if (this.isModelModalOpen) {
|
|
138
|
+
return [
|
|
139
|
+
{ key: "Enter", label: `${glyphs.enter} Escolher` },
|
|
140
|
+
{ key: "Esc", label: `${glyphs.cross} Fechar` },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
if (this.isEffortModalOpen) {
|
|
144
|
+
return [
|
|
145
|
+
{ key: "Enter", label: `${glyphs.enter} Confirmar` },
|
|
146
|
+
{ key: "Esc", label: `${glyphs.cross} Manter` },
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
return [
|
|
150
|
+
{ key: "Enter", label: `${glyphs.enter} Enviar` },
|
|
151
|
+
{ key: "Esc", label: `${glyphs.cross} Parar` },
|
|
152
|
+
{ key: "Ctrl+L", label: `${glyphs.broom} Limpar` },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private applyChosenModel(idx: number): void {
|
|
157
|
+
const chosen = this.availableModels[idx];
|
|
158
|
+
if (!chosen) return;
|
|
159
|
+
this.selectedModelIndex = idx;
|
|
160
|
+
this.isModelModalOpen = false;
|
|
161
|
+
|
|
162
|
+
const info = classifyModel(chosen);
|
|
163
|
+
if (info.category === "Texto & Raciocínio") {
|
|
164
|
+
this.isEffortModalOpen = true;
|
|
165
|
+
const effIdx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
|
|
166
|
+
this.effortSelectedIndex = effIdx !== -1 ? effIdx : 0;
|
|
167
|
+
this.statusNote = `Modelo ${chosen} escolhido. Escolha o esforço de raciocínio (Effort):`;
|
|
168
|
+
} else {
|
|
169
|
+
this.statusNote = `Modelo alterado para ${chosen}`;
|
|
170
|
+
}
|
|
171
|
+
this.onNeedsRender?.();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public async handleKey(key: KeyEvent): Promise<boolean | void> {
|
|
175
|
+
if (this.isModelModalOpen) {
|
|
176
|
+
if (key.name === "hover" && key.mouse) {
|
|
177
|
+
const { row } = key.mouse;
|
|
178
|
+
if (row >= 9 && row < 9 + this.availableModels.length) {
|
|
179
|
+
const hoverIdx = row - 9;
|
|
180
|
+
if (this.modalSelectedIndex !== hoverIdx) {
|
|
181
|
+
this.modalSelectedIndex = hoverIdx;
|
|
182
|
+
this.onNeedsRender?.();
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (key.name === "click" && key.mouse) {
|
|
188
|
+
const { row } = key.mouse;
|
|
189
|
+
if (row >= 9 && row < 9 + this.availableModels.length) {
|
|
190
|
+
const chosenIdx = row - 9;
|
|
191
|
+
this.applyChosenModel(chosenIdx);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
this.isModelModalOpen = false;
|
|
195
|
+
this.onNeedsRender?.();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
|
|
199
|
+
this.modalSelectedIndex = Math.max(0, this.modalSelectedIndex - 1);
|
|
200
|
+
this.onNeedsRender?.();
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
|
|
204
|
+
this.modalSelectedIndex = Math.min(
|
|
205
|
+
this.availableModels.length - 1,
|
|
206
|
+
this.modalSelectedIndex + 1,
|
|
207
|
+
);
|
|
208
|
+
this.onNeedsRender?.();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (key.name === "return") {
|
|
212
|
+
this.applyChosenModel(this.modalSelectedIndex);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (key.name === "escape") {
|
|
216
|
+
this.isModelModalOpen = false;
|
|
217
|
+
this.onNeedsRender?.();
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
// 2. Effort Selection Modal Active
|
|
223
|
+
if (this.isEffortModalOpen) {
|
|
224
|
+
if (key.name === "hover" && key.mouse) {
|
|
225
|
+
const { row } = key.mouse;
|
|
226
|
+
if (row >= 9 && row < 9 + this.availableEfforts.length) {
|
|
227
|
+
const hoverIdx = row - 9;
|
|
228
|
+
if (this.effortSelectedIndex !== hoverIdx) {
|
|
229
|
+
this.effortSelectedIndex = hoverIdx;
|
|
230
|
+
this.onNeedsRender?.();
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (key.name === "click" && key.mouse) {
|
|
236
|
+
const { row } = key.mouse;
|
|
237
|
+
if (row >= 9 && row < 9 + this.availableEfforts.length) {
|
|
238
|
+
this.selectedEffort = this.availableEfforts[row - 9].id;
|
|
239
|
+
this.isEffortModalOpen = false;
|
|
240
|
+
const currentM = this.availableModels[this.selectedModelIndex];
|
|
241
|
+
this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[row - 9].label}`;
|
|
242
|
+
this.onNeedsRender?.();
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
this.isEffortModalOpen = false;
|
|
246
|
+
this.onNeedsRender?.();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
|
|
250
|
+
this.effortSelectedIndex = Math.max(0, this.effortSelectedIndex - 1);
|
|
251
|
+
this.onNeedsRender?.();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
|
|
255
|
+
this.effortSelectedIndex = Math.min(
|
|
256
|
+
this.availableEfforts.length - 1,
|
|
257
|
+
this.effortSelectedIndex + 1,
|
|
258
|
+
);
|
|
259
|
+
this.onNeedsRender?.();
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (key.name === "return") {
|
|
263
|
+
this.selectedEffort = this.availableEfforts[this.effortSelectedIndex].id;
|
|
264
|
+
this.isEffortModalOpen = false;
|
|
265
|
+
const currentM = this.availableModels[this.selectedModelIndex];
|
|
266
|
+
this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[this.effortSelectedIndex].label}`;
|
|
267
|
+
this.onNeedsRender?.();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (key.name === "escape") {
|
|
271
|
+
this.isEffortModalOpen = false;
|
|
272
|
+
this.onNeedsRender?.();
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 3. Open Model Modal with F2, Ctrl+O, Alt+M, or clicking on Model Header (left half)
|
|
279
|
+
if (
|
|
280
|
+
key.name === "f2" ||
|
|
281
|
+
(key.ctrl && key.name === "o") ||
|
|
282
|
+
(key.meta && key.name === "m") ||
|
|
283
|
+
(key.name === "click" && key.mouse && key.mouse.row >= 4 && key.mouse.row <= 6 && key.mouse.col < 55)
|
|
284
|
+
) {
|
|
285
|
+
this.isModelModalOpen = true;
|
|
286
|
+
this.modalSelectedIndex = this.selectedModelIndex;
|
|
287
|
+
this.onNeedsRender?.();
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 4. Open Effort Modal directly with F3 or clicking Effort badge on header (right half)
|
|
292
|
+
if (
|
|
293
|
+
key.name === "f3" ||
|
|
294
|
+
(key.name === "click" && key.mouse && key.mouse.row >= 4 && key.mouse.row <= 6 && key.mouse.col >= 55)
|
|
295
|
+
) {
|
|
296
|
+
const currentM = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
|
|
297
|
+
const info = classifyModel(currentM);
|
|
298
|
+
if (info.category === "Texto & Raciocínio") {
|
|
299
|
+
this.isEffortModalOpen = true;
|
|
300
|
+
const idx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
|
|
301
|
+
this.effortSelectedIndex = idx !== -1 ? idx : 0;
|
|
302
|
+
this.onNeedsRender?.();
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. Abort streaming with Esc or Ctrl+C
|
|
308
|
+
if (this.isGenerating && (key.name === "escape" || (key.ctrl && key.name === "c"))) {
|
|
309
|
+
if (this.currentAbortController) {
|
|
310
|
+
this.currentAbortController.abort();
|
|
311
|
+
this.currentAbortController = null;
|
|
312
|
+
}
|
|
313
|
+
clearInterval(this.spinnerInterval!);
|
|
314
|
+
this.spinnerInterval = null;
|
|
315
|
+
this.isGenerating = false;
|
|
316
|
+
this.statusNote = theme.yellow("⚠ Geração cancelada pelo usuário");
|
|
317
|
+
this.onNeedsRender?.();
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
// 4. Chat history scrolling (Mouse wheel / PageUp / PageDown / Up / Down)
|
|
321
|
+
if (key.name === "wheelup") {
|
|
322
|
+
this.scrollOffset += 2;
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
if (key.name === "wheeldown") {
|
|
326
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 2);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
if (key.name === "pageup") {
|
|
330
|
+
this.scrollOffset += 6;
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (key.name === "pagedown") {
|
|
334
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 6);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if ((key.ctrl || key.shift) && key.name === "up") {
|
|
338
|
+
this.scrollOffset += 2;
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
if ((key.ctrl || key.shift) && key.name === "down") {
|
|
342
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 2);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "up") {
|
|
346
|
+
this.scrollOffset += 1;
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "down") {
|
|
350
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 5. Cursor movement within input line using Left / Right
|
|
355
|
+
if (key.name === "left") {
|
|
356
|
+
this.cursorPos = Math.max(0, this.cursorPos - 1);
|
|
357
|
+
this.onNeedsRender?.();
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
if (key.name === "right") {
|
|
361
|
+
this.cursorPos = Math.min(this.inputBuffer.length, this.cursorPos + 1);
|
|
362
|
+
this.onNeedsRender?.();
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (key.name === "home") {
|
|
366
|
+
this.cursorPos = 0;
|
|
367
|
+
this.onNeedsRender?.();
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
if (key.name === "end") {
|
|
371
|
+
this.cursorPos = this.inputBuffer.length;
|
|
372
|
+
this.onNeedsRender?.();
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
// 5. Paste from clipboard with Ctrl+V
|
|
376
|
+
if (key.ctrl && (key.name === "v" || key.raw === "\x16")) {
|
|
377
|
+
const { getClipboardText } = require("../theme.ts");
|
|
378
|
+
const pasted = getClipboardText();
|
|
379
|
+
if (pasted) {
|
|
380
|
+
this.inputBuffer =
|
|
381
|
+
this.inputBuffer.slice(0, this.cursorPos) +
|
|
382
|
+
pasted +
|
|
383
|
+
this.inputBuffer.slice(this.cursorPos);
|
|
384
|
+
this.cursorPos += pasted.length;
|
|
385
|
+
this.onNeedsRender?.();
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 6. Clear history with Ctrl+L
|
|
391
|
+
if (key.ctrl && key.name === "l") {
|
|
392
|
+
this.messages = [];
|
|
393
|
+
this.statusNote = "Histórico do chat limpo.";
|
|
394
|
+
this.onNeedsRender?.();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 6. Clear input line with single Ctrl+C (standard CLI behavior)
|
|
399
|
+
if (key.ctrl && key.name === "c" && this.inputBuffer.length > 0) {
|
|
400
|
+
this.inputBuffer = "";
|
|
401
|
+
this.cursorPos = 0;
|
|
402
|
+
this.onNeedsRender?.();
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 7. Backspace at cursor
|
|
407
|
+
if (key.name === "backspace") {
|
|
408
|
+
if (this.cursorPos > 0) {
|
|
409
|
+
this.inputBuffer =
|
|
410
|
+
this.inputBuffer.slice(0, this.cursorPos - 1) +
|
|
411
|
+
this.inputBuffer.slice(this.cursorPos);
|
|
412
|
+
this.cursorPos--;
|
|
413
|
+
this.onNeedsRender?.();
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 7. Delete at cursor
|
|
419
|
+
if (key.name === "delete") {
|
|
420
|
+
if (this.cursorPos < this.inputBuffer.length) {
|
|
421
|
+
this.inputBuffer =
|
|
422
|
+
this.inputBuffer.slice(0, this.cursorPos) +
|
|
423
|
+
this.inputBuffer.slice(this.cursorPos + 1);
|
|
424
|
+
this.onNeedsRender?.();
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 8. Submit message on Enter
|
|
430
|
+
if (key.name === "return") {
|
|
431
|
+
const text = this.inputBuffer.trim();
|
|
432
|
+
if (!text || this.isGenerating) return true;
|
|
433
|
+
|
|
434
|
+
// Block submitting if there are 0 accounts configured
|
|
435
|
+
if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
|
|
436
|
+
this.statusNote = theme.yellow("[!] Adicione uma conta na aba [5] Contas antes de iniciar o chat.");
|
|
437
|
+
this.onNeedsRender?.();
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const serverState = ServerManager.getInstance().getState();
|
|
442
|
+
if (serverState === "warming") {
|
|
443
|
+
this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
|
|
444
|
+
this.onNeedsRender?.();
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (serverState === "error") {
|
|
448
|
+
this.statusNote = theme.red("Servidor com erro. Verifique a aba Logs.");
|
|
449
|
+
this.onNeedsRender?.();
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
this.inputBuffer = "";
|
|
453
|
+
this.cursorPos = 0;
|
|
454
|
+
this.scrollOffset = 0;
|
|
455
|
+
this.sendMessage(text);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 9. Ignore Tab key (handled globally for tab switching)
|
|
460
|
+
if (key.name === "tab") {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// 10. Character typing (insert at cursor position)
|
|
464
|
+
if (key.char && !key.ctrl && !key.meta && key.name !== "tab") {
|
|
465
|
+
if (key.char >= " ") {
|
|
466
|
+
this.inputBuffer =
|
|
467
|
+
this.inputBuffer.slice(0, this.cursorPos) +
|
|
468
|
+
key.char +
|
|
469
|
+
this.inputBuffer.slice(this.cursorPos);
|
|
470
|
+
this.cursorPos += key.char.length;
|
|
471
|
+
this.onNeedsRender?.();
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async sendMessage(userText: string): Promise<void> {
|
|
478
|
+
const serverState = ServerManager.getInstance().getState();
|
|
479
|
+
if (serverState === "warming") {
|
|
480
|
+
this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const model = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
|
|
485
|
+
|
|
486
|
+
this.messages.push({
|
|
487
|
+
role: "user",
|
|
488
|
+
content: userText,
|
|
489
|
+
});
|
|
490
|
+
const assistantMsgIndex = this.messages.length;
|
|
491
|
+
this.messages.push({
|
|
492
|
+
role: "assistant",
|
|
493
|
+
content: "",
|
|
494
|
+
reasoning: "",
|
|
495
|
+
model,
|
|
496
|
+
});
|
|
497
|
+
this.isGenerating = true;
|
|
498
|
+
this.spinnerIndex = 0;
|
|
499
|
+
clearInterval(this.spinnerInterval!);
|
|
500
|
+
this.spinnerInterval = setInterval(() => {
|
|
501
|
+
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
|
502
|
+
this.scheduleRender();
|
|
503
|
+
}, 100);
|
|
504
|
+
this.statusNote = "";
|
|
505
|
+
this.currentAbortController = new AbortController();
|
|
506
|
+
this.flushRender();
|
|
507
|
+
|
|
508
|
+
const conversationPayload = this.messages
|
|
509
|
+
.slice(0, -1)
|
|
510
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const isReasoning = classifyModel(model).category === "Texto & Raciocínio";
|
|
514
|
+
const result = await streamChatCompletions({
|
|
515
|
+
model,
|
|
516
|
+
reasoning_effort: isReasoning ? this.selectedEffort : undefined,
|
|
517
|
+
messages: conversationPayload,
|
|
518
|
+
signal: this.currentAbortController.signal,
|
|
519
|
+
onReasoning: (chunk) => {
|
|
520
|
+
const current = this.messages[assistantMsgIndex];
|
|
521
|
+
if (current) {
|
|
522
|
+
current.reasoning = (current.reasoning || "") + chunk;
|
|
523
|
+
this.scheduleRender();
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
onToken: (chunk) => {
|
|
527
|
+
const current = this.messages[assistantMsgIndex];
|
|
528
|
+
if (current) {
|
|
529
|
+
current.content += chunk;
|
|
530
|
+
this.scheduleRender();
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const current = this.messages[assistantMsgIndex];
|
|
536
|
+
if (current) {
|
|
537
|
+
current.ttfbMs = result.ttfbMs;
|
|
538
|
+
current.totalTimeMs = result.totalTimeMs;
|
|
539
|
+
}
|
|
540
|
+
this.statusNote = theme.green(
|
|
541
|
+
`✓ Resposta concluída em ${(result.totalTimeMs / 1000).toFixed(2)}s (TTFB: ${result.ttfbMs}ms)`,
|
|
542
|
+
);
|
|
543
|
+
} catch (err: any) {
|
|
544
|
+
const current = this.messages[assistantMsgIndex];
|
|
545
|
+
let rawMsg = err?.message || String(err);
|
|
546
|
+
try {
|
|
547
|
+
const jsonMatch = rawMsg.match(/\{.*"error"\s*:\s*\{.*\}\s*\}/s);
|
|
548
|
+
if (jsonMatch) {
|
|
549
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
550
|
+
if (parsed?.error?.message) {
|
|
551
|
+
rawMsg = parsed.error.message;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
} catch {}
|
|
555
|
+
|
|
556
|
+
if (
|
|
557
|
+
rawMsg.includes("Target page, context or browser has been closed") ||
|
|
558
|
+
rawMsg.includes("browserType.launchPersistentContext")
|
|
559
|
+
) {
|
|
560
|
+
rawMsg = "Falha ao iniciar o navegador da conta. Feche outras instâncias do QwenProxy ou do Chrome e execute 'qpx reset'.";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (current) {
|
|
564
|
+
current.content = `\n ${theme.red("❌ " + rawMsg)}`;
|
|
565
|
+
}
|
|
566
|
+
this.statusNote = theme.red(`✗ ${rawMsg.slice(0, 80)}`);
|
|
567
|
+
} finally {
|
|
568
|
+
clearInterval(this.spinnerInterval!);
|
|
569
|
+
this.spinnerInterval = null;
|
|
570
|
+
this.isGenerating = false;
|
|
571
|
+
this.currentAbortController = null;
|
|
572
|
+
this.flushRender();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
public render(width: number, height: number, snapshot?: ProxyStatusSnapshot | null): string[] {
|
|
577
|
+
if (snapshot !== undefined) {
|
|
578
|
+
this.lastSnapshot = snapshot ?? null;
|
|
579
|
+
}
|
|
580
|
+
const hasAccounts = !this.lastSnapshot || this.lastSnapshot.accounts.length > 0;
|
|
581
|
+
const totalLines: string[] = [];
|
|
582
|
+
|
|
583
|
+
// 1. Model Header with F2 Shortcut
|
|
584
|
+
const currentModel = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
|
|
585
|
+
const currentInfo = classifyModel(currentModel);
|
|
586
|
+
const totalModels = this.availableModels.length;
|
|
587
|
+
const isReasoning = currentInfo.category === "Texto & Raciocínio";
|
|
588
|
+
|
|
589
|
+
const effortBadge = isReasoning
|
|
590
|
+
? this.selectedEffort === "high"
|
|
591
|
+
? theme.green(`[ Effort: High (Thinking) ]`)
|
|
592
|
+
: this.selectedEffort === "medium"
|
|
593
|
+
? theme.yellow(`[ Effort: Medium (Auto) ]`)
|
|
594
|
+
: theme.cyan(`[ Effort: Low (Fast) ]`)
|
|
595
|
+
: "";
|
|
596
|
+
|
|
597
|
+
const headerLine = hasAccounts
|
|
598
|
+
? ` ${theme.bold("Modelo:")} ${theme.cyan(`[ ${currentModel} ]`)} ${currentInfo.badge} ${isReasoning ? effortBadge : theme.muted(`• ${currentInfo.category}`)} ${theme.yellow(`[ F2: Modelo${isReasoning ? " | F3: Effort" : ""} (${this.selectedModelIndex + 1}/${totalModels}) ]`)}`
|
|
599
|
+
: ` ${theme.bold("Modelo:")} ${theme.cyan(`[ ${currentModel} ]`)} ${currentInfo.badge} ${theme.yellow("[ [!] Sem Contas: Adicione em [5] Contas ]")}`;
|
|
600
|
+
const headerBox = drawBox({
|
|
601
|
+
title: "Chat Tester",
|
|
602
|
+
width,
|
|
603
|
+
height: 3,
|
|
604
|
+
borderColor: theme.borderActive,
|
|
605
|
+
titleColor: theme.blue,
|
|
606
|
+
content: [headerLine],
|
|
607
|
+
});
|
|
608
|
+
totalLines.push(...headerBox);
|
|
609
|
+
|
|
610
|
+
// 2. Main Middle Area: Either Vertical Model Modal OR Chat Conversation
|
|
611
|
+
const chatHeight = Math.max(6, height - 6);
|
|
612
|
+
const innerChatW = width - 4;
|
|
613
|
+
|
|
614
|
+
if (this.isModelModalOpen) {
|
|
615
|
+
const modalLines: string[] = [""];
|
|
616
|
+
for (let i = 0; i < this.availableModels.length; i++) {
|
|
617
|
+
const m = this.availableModels[i];
|
|
618
|
+
const isSel = i === this.modalSelectedIndex;
|
|
619
|
+
const pointer = isSel ? theme.cyan("▸ ") : " ";
|
|
620
|
+
const info = classifyModel(m);
|
|
621
|
+
const line = `${pointer}${info.badge} ${pad(m, 20)} • ${info.category}`;
|
|
622
|
+
modalLines.push(isSel ? theme.bgSelected(line) : line);
|
|
623
|
+
}
|
|
624
|
+
modalLines.push("");
|
|
625
|
+
|
|
626
|
+
const modalBox = drawBox({
|
|
627
|
+
title: "Selecionar Modelo [ Enter: Escolher • Esc: Fechar ]",
|
|
628
|
+
width,
|
|
629
|
+
height: chatHeight,
|
|
630
|
+
borderColor: theme.borderActive,
|
|
631
|
+
titleColor: theme.cyan,
|
|
632
|
+
content: modalLines,
|
|
633
|
+
});
|
|
634
|
+
totalLines.push(...modalBox);
|
|
635
|
+
} else if (this.isEffortModalOpen) {
|
|
636
|
+
const modalLines: string[] = [
|
|
637
|
+
"",
|
|
638
|
+
` ${theme.bold("Modelo:")} ${theme.cyan(currentModel)} (${currentInfo.category})`,
|
|
639
|
+
` ${theme.dim("Escolha o nível de esforço de raciocínio (reasoning_effort):")}`,
|
|
640
|
+
"",
|
|
641
|
+
];
|
|
642
|
+
for (let i = 0; i < this.availableEfforts.length; i++) {
|
|
643
|
+
const eff = this.availableEfforts[i];
|
|
644
|
+
const isSel = i === this.effortSelectedIndex;
|
|
645
|
+
const isCurrent = eff.id === this.selectedEffort;
|
|
646
|
+
const pointer = isSel ? theme.cyan("▸ ") : " ";
|
|
647
|
+
const radio = isCurrent ? theme.green(glyphs.radioOn) : theme.muted(glyphs.radioOff);
|
|
648
|
+
const line = `${pointer}${radio} ${eff.badge} ${pad(eff.label, 18)} • ${eff.desc}`;
|
|
649
|
+
modalLines.push(isSel ? theme.bgSelected(line) : line);
|
|
650
|
+
}
|
|
651
|
+
modalLines.push("");
|
|
652
|
+
|
|
653
|
+
const modalBox = drawBox({
|
|
654
|
+
title: "Nível de Raciocínio / Effort [ Enter: Confirmar • Esc: Manter ]",
|
|
655
|
+
width,
|
|
656
|
+
height: chatHeight,
|
|
657
|
+
borderColor: theme.borderActive,
|
|
658
|
+
titleColor: theme.yellow,
|
|
659
|
+
content: modalLines,
|
|
660
|
+
});
|
|
661
|
+
totalLines.push(...modalBox);
|
|
662
|
+
} else {
|
|
663
|
+
const chatContent: string[] = [];
|
|
664
|
+
|
|
665
|
+
if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
|
|
666
|
+
chatContent.push("");
|
|
667
|
+
chatContent.push(` ${theme.yellow("[!] Nenhuma conta Qwen configurada no servidor.")}`);
|
|
668
|
+
chatContent.push(` ${theme.muted(" Pressione ")}${theme.cyan("Tab")}${theme.muted(" para ir até ")}${theme.bold(theme.white("[5] Contas"))}${theme.muted(" e pressione ")}${theme.bold(theme.white("'A'"))}${theme.muted(" para adicionar seu e-mail e senha.")}`);
|
|
669
|
+
} else if (this.messages.length === 0) {
|
|
670
|
+
chatContent.push("");
|
|
671
|
+
const serverState = ServerManager.getInstance().getState();
|
|
672
|
+
if (serverState === "warming") {
|
|
673
|
+
chatContent.push(theme.yellow(" [!] Inicializando proxy..."));
|
|
674
|
+
} else {
|
|
675
|
+
chatContent.push(theme.muted(" Digite sua mensagem..."));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const msg of this.messages) {
|
|
679
|
+
chatContent.push("");
|
|
680
|
+
if (msg.role === "user") {
|
|
681
|
+
const userLines = msg.content.split(/\r?\n/);
|
|
682
|
+
for (let u = 0; u < userLines.length; u++) {
|
|
683
|
+
if (u === 0) {
|
|
684
|
+
chatContent.push(` ${theme.blue(glyphs.pointer + " Você:")} ${theme.white(userLines[u])}`);
|
|
685
|
+
} else {
|
|
686
|
+
chatContent.push(` ${theme.white(userLines[u])}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
const messageModel = msg.model || currentModel;
|
|
691
|
+
chatContent.push(` ${theme.green(glyphs.bullet + " Qwen (" + messageModel + "):")}`);
|
|
692
|
+
// 1. Dedicated Thinking (Reasoning) Container - Opaque, Dimmed, and Cached
|
|
693
|
+
if (msg.reasoning && msg.reasoning.trim().length > 0) {
|
|
694
|
+
const thinkWidth = Math.max(20, innerChatW - 4);
|
|
695
|
+
let thinkLines: string[];
|
|
696
|
+
|
|
697
|
+
if (msg.cachedWidth === innerChatW && msg.cachedReasoningBox) {
|
|
698
|
+
thinkLines = msg.cachedReasoningBox;
|
|
699
|
+
} else {
|
|
700
|
+
const rLines = formatReasoning(msg.reasoning, thinkWidth - 4).map((l) => ` ${l}`);
|
|
701
|
+
if (this.isGenerating && !msg.content && this.messages.indexOf(msg) === this.messages.length - 1) {
|
|
702
|
+
const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
|
|
703
|
+
rLines.push("");
|
|
704
|
+
rLines.push(` ${theme.yellow(`${spinner} Raciocinando...`)}`);
|
|
705
|
+
}
|
|
706
|
+
thinkLines = drawBox({
|
|
707
|
+
title: "🧠 Raciocínio",
|
|
708
|
+
width: thinkWidth,
|
|
709
|
+
borderColor: theme.borderInactive,
|
|
710
|
+
titleColor: theme.muted,
|
|
711
|
+
content: rLines,
|
|
712
|
+
});
|
|
713
|
+
if (!this.isGenerating) {
|
|
714
|
+
msg.cachedReasoningBox = thinkLines;
|
|
715
|
+
msg.cachedWidth = innerChatW;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
for (const line of thinkLines) {
|
|
720
|
+
chatContent.push(` ${line}`);
|
|
721
|
+
}
|
|
722
|
+
chatContent.push("");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 2. Final Response - Bright, crisp, full rich Markdown!
|
|
726
|
+
if (msg.content) {
|
|
727
|
+
let contentLines: string[];
|
|
728
|
+
if (msg.cachedWidth === innerChatW && msg.cachedContentLines) {
|
|
729
|
+
contentLines = msg.cachedContentLines;
|
|
730
|
+
} else {
|
|
731
|
+
contentLines = formatMarkdown(msg.content, innerChatW - 6, { dim: false });
|
|
732
|
+
if (!this.isGenerating) {
|
|
733
|
+
msg.cachedContentLines = contentLines;
|
|
734
|
+
msg.cachedWidth = innerChatW;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const line of contentLines) {
|
|
738
|
+
chatContent.push(` ${line}`);
|
|
739
|
+
}
|
|
740
|
+
} else if (this.isGenerating && !msg.reasoning && this.messages.indexOf(msg) === this.messages.length - 1) {
|
|
741
|
+
const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
|
|
742
|
+
chatContent.push(` ${theme.yellow(`${spinner} Pensando...`)}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (msg.totalTimeMs) {
|
|
746
|
+
chatContent.push(
|
|
747
|
+
` ${theme.dim(`[TTFB: ${msg.ttfbMs}ms | Total: ${(msg.totalTimeMs / 1000).toFixed(2)}s]`)}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Flatten and pre-wrap chatContent to innerChatW so every element maps strictly 1:1 to terminal rows
|
|
754
|
+
const flatChatContent: string[] = [];
|
|
755
|
+
for (const item of chatContent) {
|
|
756
|
+
const sub = String(item ?? "").split(/\r?\n/);
|
|
757
|
+
for (const s of sub) {
|
|
758
|
+
if (stringWidth(s) <= innerChatW) {
|
|
759
|
+
flatChatContent.push(s);
|
|
760
|
+
} else {
|
|
761
|
+
flatChatContent.push(...wrapContentLine(s, innerChatW));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Auto-scroll window calculation supporting 1:1 smooth and precise history scrolling
|
|
767
|
+
const visibleCapacity = Math.max(1, chatHeight - 2);
|
|
768
|
+
const maxOffset = Math.max(0, flatChatContent.length - visibleCapacity);
|
|
769
|
+
const clampedOffset = Math.min(this.scrollOffset, maxOffset);
|
|
770
|
+
const startIndex = Math.max(0, flatChatContent.length - visibleCapacity - clampedOffset);
|
|
771
|
+
const visibleChatLines = flatChatContent.slice(startIndex, startIndex + visibleCapacity);
|
|
772
|
+
const scrollIndicator =
|
|
773
|
+
clampedOffset > 0 ? theme.yellow(` [▲ Rolar: +${clampedOffset}]`) : "";
|
|
774
|
+
|
|
775
|
+
const historyBox = drawBox({
|
|
776
|
+
title: `Conversa (${this.messages.length})${scrollIndicator}`,
|
|
777
|
+
width,
|
|
778
|
+
height: chatHeight,
|
|
779
|
+
borderColor: theme.borderInactive,
|
|
780
|
+
titleColor: theme.lavender,
|
|
781
|
+
content: visibleChatLines,
|
|
782
|
+
});
|
|
783
|
+
totalLines.push(...historyBox);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// 3. Bottom Input Box
|
|
787
|
+
const inputPrompt = " ❯ ";
|
|
788
|
+
const beforeCursor = this.inputBuffer.slice(0, this.cursorPos);
|
|
789
|
+
const atCursor = this.inputBuffer[this.cursorPos] || " ";
|
|
790
|
+
const afterCursor = this.inputBuffer.slice(this.cursorPos + 1);
|
|
791
|
+
const styledCursor = this.isGenerating ? "" : theme.inverse(atCursor);
|
|
792
|
+
const formattedInput = `${beforeCursor}${styledCursor}${afterCursor}`;
|
|
793
|
+
const availableInputW = width - stringWidth(inputPrompt) - 4;
|
|
794
|
+
const displayInput = truncate(formattedInput, availableInputW);
|
|
795
|
+
const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
|
|
796
|
+
|
|
797
|
+
const inputContent = [`${theme.cyan(inputPrompt)}${displayInput}`];
|
|
798
|
+
const actionLabel = !hasAccounts
|
|
799
|
+
? "[!] Nenhuma conta configurada — adicione uma conta na aba [5] Contas"
|
|
800
|
+
: currentInfo.category === "Geração de Imagem"
|
|
801
|
+
? "Prompt da Imagem"
|
|
802
|
+
: currentInfo.category === "Geração de Vídeo"
|
|
803
|
+
? "Prompt do Vídeo"
|
|
804
|
+
: "Mensagem";
|
|
805
|
+
|
|
806
|
+
const inputTitle = this.isGenerating
|
|
807
|
+
? `${spinner} Gerando... (Esc para cancelar)`
|
|
808
|
+
: actionLabel;
|
|
809
|
+
|
|
810
|
+
const inputBox = drawBox({
|
|
811
|
+
title: inputTitle,
|
|
812
|
+
width,
|
|
813
|
+
height: 3,
|
|
814
|
+
borderColor: this.isGenerating ? theme.yellow : theme.borderActive,
|
|
815
|
+
titleColor: this.isGenerating ? theme.yellow : theme.cyan,
|
|
816
|
+
footer: this.statusNote ? stripAnsi(this.statusNote) : undefined,
|
|
817
|
+
content: inputContent,
|
|
818
|
+
});
|
|
819
|
+
totalLines.push(...inputBox);
|
|
820
|
+
|
|
821
|
+
return totalLines;
|
|
822
|
+
}
|
|
823
|
+
}
|