qwenproxy-cli 1.0.25 → 1.0.27

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.
@@ -53,6 +53,8 @@ export {
53
53
  updateSessionParent,
54
54
  invalidateLogicalThreadParent,
55
55
  clearAllSessionsForAccount,
56
+ removeSessionByChatId,
57
+ isChatSessionActive,
56
58
  getSessionParent,
57
59
  } from "./qwen-thread-state.ts";
58
60
  export type { LogicalThreadEntry } from "./qwen-thread-state.ts";
@@ -74,6 +76,8 @@ import {
74
76
  } from "./qwen-errors.ts";
75
77
  import {
76
78
  clearAllSessionsForAccount,
79
+ removeSessionByChatId,
80
+ isChatSessionActive,
77
81
  getSessionParent,
78
82
  updateSessionParent,
79
83
  } from "./qwen-thread-state.ts";
@@ -1977,6 +1981,112 @@ export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
1977
1981
  return true;
1978
1982
  }
1979
1983
 
1984
+ export interface RemoteQwenChat {
1985
+ id: string;
1986
+ title: string;
1987
+ updated_at: number | string;
1988
+ created_at: number | string;
1989
+ }
1990
+
1991
+ /**
1992
+ * Deletes a single chat session by ID on Qwen Web.
1993
+ */
1994
+ export async function deleteSingleQwenChat(
1995
+ accountId: string | undefined,
1996
+ chatId: string,
1997
+ ): Promise<boolean> {
1998
+ if (!chatId) return false;
1999
+
2000
+ if (isAuthMockEnabled()) {
2001
+ const url = qwenUrl(`/api/v2/chats/${encodeURIComponent(chatId)}`);
2002
+ const response = await fetch(url, {
2003
+ method: "DELETE",
2004
+ });
2005
+ removeSessionByChatId(chatId);
2006
+ return response.ok;
2007
+ }
2008
+
2009
+ const requestHeaders: Record<string, string> = {
2010
+ source: "web",
2011
+ version: "0.2.89",
2012
+ timezone: new Date().toString().split(" (")[0],
2013
+ "x-request-id": crypto.randomUUID(),
2014
+ Referer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
2015
+ };
2016
+
2017
+ try {
2018
+ const response = await requestQwenTextInBrowser(
2019
+ accountId,
2020
+ "DELETE",
2021
+ `/api/v2/chats/${encodeURIComponent(chatId)}`,
2022
+ requestHeaders,
2023
+ undefined,
2024
+ { referrer: qwenUrl(`/c/${encodeURIComponent(chatId)}`), noMutexRecovery: true },
2025
+ );
2026
+
2027
+ const { json: parsed } = await readJsonTextResponse(response, {
2028
+ strict: false,
2029
+ });
2030
+
2031
+ const success = response.ok && parsed?.success === true && parsed?.data?.status === true;
2032
+ if (success) {
2033
+ removeSessionByChatId(chatId);
2034
+ }
2035
+ return success;
2036
+ } catch (error) {
2037
+ logger.debug("[Qwen] deleteSingleQwenChat failed", {
2038
+ accountId,
2039
+ chatId,
2040
+ error: error instanceof Error ? error.message : String(error),
2041
+ });
2042
+ return false;
2043
+ }
2044
+ }
2045
+
2046
+ /**
2047
+ * Fetch remote chat list from Qwen Web.
2048
+ */
2049
+ export async function fetchRemoteQwenChats(
2050
+ accountId?: string,
2051
+ ): Promise<RemoteQwenChat[]> {
2052
+ if (isAuthMockEnabled()) {
2053
+ try {
2054
+ const response = await fetch(qwenUrl("/api/v2/chats/?page=1&exclude_project=true"));
2055
+ const json: any = await response.json().catch(() => null);
2056
+ if (json?.success && Array.isArray(json.data)) {
2057
+ return json.data;
2058
+ }
2059
+ } catch {}
2060
+ return [];
2061
+ }
2062
+
2063
+ const requestHeaders: Record<string, string> = {
2064
+ version: "0.2.89",
2065
+ timezone: new Date().toString().split(" (")[0],
2066
+ "x-request-id": crypto.randomUUID(),
2067
+ Referer: qwenUrl("/settings/chats"),
2068
+ };
2069
+
2070
+ try {
2071
+ const response = await requestQwenTextInBrowser(
2072
+ accountId,
2073
+ "GET",
2074
+ "/api/v2/chats/?page=1&exclude_project=true",
2075
+ requestHeaders,
2076
+ undefined,
2077
+ { referrer: qwenUrl("/settings/chats"), noMutexRecovery: true },
2078
+ );
2079
+ if (!response.ok) return [];
2080
+ const json: any = await response.json().catch(() => null);
2081
+ if (json?.success && Array.isArray(json.data)) {
2082
+ return json.data;
2083
+ }
2084
+ return [];
2085
+ } catch {
2086
+ return [];
2087
+ }
2088
+ }
2089
+
1980
2090
  export async function fetchQwenModels(
1981
2091
  accountId?: string,
1982
2092
  ): Promise<PublicQwenModel[]> {
package/src/sync/index.ts CHANGED
@@ -115,8 +115,12 @@ export function inspectClientSyncStatus(
115
115
  const data = JSON.parse(raw);
116
116
  const url = data?.env?.ANTHROPIC_BASE_URL || "";
117
117
  const model = data?.env?.ANTHROPIC_MODEL || data?.model || "";
118
+ const isLocalHost =
119
+ url.includes(String(port)) ||
120
+ url.includes(`127.0.0.1:${port}`) ||
121
+ url.includes(`localhost:${port}`);
118
122
  const isSynced =
119
- (url.includes(String(port)) || url.includes("qwenproxy")) &&
123
+ isLocalHost &&
120
124
  (model.toLowerCase().includes("qwen") || Boolean(data?.env?.ANTHROPIC_AUTH_TOKEN));
121
125
  return {
122
126
  id,
@@ -132,34 +136,66 @@ export function inspectClientSyncStatus(
132
136
  const isProviderActive = /^model_provider\s*=\s*["']qwenproxy["']/m.test(raw);
133
137
  const modelMatch = raw.match(/^model\s*=\s*["']([^"']+)["']/m);
134
138
  const model = modelMatch ? modelMatch[1] : undefined;
139
+ const urlMatch = raw.match(/\[model_providers\.qwenproxy\][\s\S]*?base_url\s*=\s*["']([^"']+)["']/);
140
+ const url = urlMatch ? urlMatch[1] : "";
141
+ const isLocalHost = Boolean(
142
+ url && (url.includes(String(port)) || url.includes(`127.0.0.1:${port}`) || url.includes(`localhost:${port}`)),
143
+ );
135
144
  return {
136
145
  id,
137
146
  installed: true,
138
- synced: hasProvider && isProviderActive,
147
+ synced: hasProvider && isProviderActive && isLocalHost,
139
148
  model,
140
149
  };
141
150
  }
142
151
 
143
152
  if (id === "opencode") {
144
- const hasQwen =
145
- raw.includes('"qwenproxy"') &&
146
- (raw.includes(String(port)) || raw.includes("QwenProxy") || raw.includes("qwen3"));
153
+ let isSynced = false;
154
+ try {
155
+ const data = JSON.parse(raw);
156
+ const provider = data?.provider?.qwenproxy;
157
+ const url = provider?.options?.baseURL || "";
158
+ const isLocalHost =
159
+ url.includes(String(port)) ||
160
+ url.includes(`127.0.0.1:${port}`) ||
161
+ url.includes(`localhost:${port}`);
162
+ isSynced = Boolean(provider && isLocalHost);
163
+ } catch {
164
+ const qwenBlockMatch = raw.match(/"qwenproxy"\s*:\s*\{[\s\S]*?"baseURL"\s*:\s*"([^"]+)"/);
165
+ const url = qwenBlockMatch ? qwenBlockMatch[1] : "";
166
+ isSynced = Boolean(
167
+ url &&
168
+ (url.includes(String(port)) ||
169
+ url.includes(`127.0.0.1:${port}`) ||
170
+ url.includes(`localhost:${port}`)),
171
+ );
172
+ }
147
173
  return {
148
174
  id,
149
175
  installed: true,
150
- synced: hasQwen,
176
+ synced: isSynced,
151
177
  };
152
178
  }
153
179
 
154
180
  if (id === "omp") {
155
- const hasQwen =
156
- /^ {2}qwenproxy:\s*$/m.test(raw) ||
157
- raw.includes("qwenproxy:") ||
158
- (raw.includes(String(port)) && raw.includes("qwen3"));
181
+ const ompMatch = raw.match(/^[ \t]*qwenproxy:\s*\r?\n((?:[ \t]{4,}.*\r?\n?)*)/m);
182
+ let isSynced = false;
183
+ let url: string | undefined;
184
+ if (ompMatch) {
185
+ const urlMatch = ompMatch[1].match(/baseUrl:\s*(\S+)/);
186
+ url = urlMatch ? urlMatch[1].replace(/['"]/g, "") : undefined;
187
+ isSynced = Boolean(
188
+ url &&
189
+ (url.includes(String(port)) ||
190
+ url.includes(`127.0.0.1:${port}`) ||
191
+ url.includes(`localhost:${port}`)),
192
+ );
193
+ }
159
194
  return {
160
195
  id,
161
196
  installed: true,
162
- synced: hasQwen,
197
+ synced: isSynced,
198
+ url,
163
199
  };
164
200
  }
165
201
  } catch {
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { robustParseJSON, computeMissingJsonClosingTokens } from "../utils/json.ts";
3
3
  import { logger, isToolcallDebugEnabled } from "../core/logger.js";
4
+ import { metrics } from "../core/metrics.ts";
4
5
  import type { ParsedToolCall } from "./types";
5
6
  import type { FunctionToolDefinition } from "./types";
6
7
  import {
@@ -1501,7 +1502,7 @@ export class StreamingToolParser {
1501
1502
  }
1502
1503
 
1503
1504
  this.emittedCallKeys.add(key);
1504
-
1505
+ metrics.increment("toolcalls.total");
1505
1506
  const incremental = this.activeIncrementalToolCall;
1506
1507
  const matchesIncrementalCall =
1507
1508
  incremental?.name === tc.name && incremental.startEmitted;
@@ -1725,6 +1726,7 @@ export class StreamingToolParser {
1725
1726
  failureReason: options.failureReason,
1726
1727
  recoveryAttempts: options.recoveryAttempts,
1727
1728
  });
1729
+ metrics.increment("toolcalls.malformed");
1728
1730
  }
1729
1731
 
1730
1732
  private extractUndeclaredNamesFromContent(text: string): string[] {
@@ -1989,6 +1991,7 @@ export class StreamingToolParser {
1989
1991
  }
1990
1992
  }
1991
1993
  if (recovered) {
1994
+ metrics.increment("toolcalls.recovered");
1992
1995
  if (isToolcallDebugEnabled()) {
1993
1996
  logger.debug("[parser] flush: recovery successful", {
1994
1997
  name: recovered.name,
package/src/tui/app.ts CHANGED
@@ -26,6 +26,7 @@ import { SyncView } from "./views/sync-view.ts";
26
26
  import { StorageView } from "./views/storage-view.ts";
27
27
  import { AccountsView } from "./views/accounts-view.ts";
28
28
  import { LogsView } from "./views/logs-view.ts";
29
+ import { loadTuiSettings, saveTuiSettings } from "./settings.ts";
29
30
  export class TuiApp {
30
31
  private screen: Screen;
31
32
  private views: TuiView[] = [];
@@ -36,7 +37,7 @@ export class TuiApp {
36
37
  private pollInterval: NodeJS.Timeout | null = null;
37
38
  private statusSnapshot: ProxyStatusSnapshot | null = null;
38
39
  private renderScheduled = false;
39
- constructor(initialTab = 1) {
40
+ constructor(initialTab?: number) {
40
41
  this.screen = new Screen();
41
42
 
42
43
  this.views = [
@@ -47,10 +48,33 @@ export class TuiApp {
47
48
  new AccountsView(),
48
49
  new LogsView(),
49
50
  ];
50
- const tabIdx = Math.max(0, Math.min(this.views.length - 1, initialTab - 1));
51
+
52
+ let resolvedTab = initialTab;
53
+ if (!resolvedTab || isNaN(resolvedTab)) {
54
+ const saved = loadTuiSettings();
55
+ if (saved.lastTab && saved.lastTab >= 1 && saved.lastTab <= 6) {
56
+ resolvedTab = saved.lastTab;
57
+ } else {
58
+ resolvedTab = 1;
59
+ }
60
+ }
61
+
62
+ const tabIdx = Math.max(0, Math.min(this.views.length - 1, resolvedTab - 1));
51
63
  this.activeViewIndex = tabIdx;
52
64
  }
53
65
 
66
+ private switchTab(newIdx: number): void {
67
+ if (newIdx === this.activeViewIndex || !this.views[newIdx]) return;
68
+ const activeView = this.views[this.activeViewIndex];
69
+ if (activeView.onDeactivate) activeView.onDeactivate();
70
+ this.activeViewIndex = newIdx;
71
+ this.hoveredTabIndex = null;
72
+ const nextView = this.views[this.activeViewIndex];
73
+ if (nextView.onActivate) nextView.onActivate();
74
+ saveTuiSettings({ lastTab: newIdx + 1 });
75
+ this.requestRender();
76
+ }
77
+
54
78
  private getTabAtCol(col: number): number | null {
55
79
  let curCol = 3;
56
80
  for (let i = 0; i < this.views.length; i++) {
@@ -149,12 +173,7 @@ export class TuiApp {
149
173
  }
150
174
  if (key.name === "click") {
151
175
  if (tabIdx !== null && tabIdx !== this.activeViewIndex && this.views[tabIdx]) {
152
- if (activeView.onDeactivate) activeView.onDeactivate();
153
- this.activeViewIndex = tabIdx;
154
- this.hoveredTabIndex = null;
155
- const nextView = this.views[this.activeViewIndex];
156
- if (nextView.onActivate) nextView.onActivate();
157
- this.requestRender();
176
+ this.switchTab(tabIdx);
158
177
  return;
159
178
  }
160
179
  }
@@ -189,12 +208,7 @@ export class TuiApp {
189
208
  // Tab for cycling tabs across views (including from Chat view when no modal is open!)
190
209
  if (key.name === "tab" && !isModalOpen) {
191
210
  const newIdx = (this.activeViewIndex + 1) % this.views.length;
192
- if (activeView.onDeactivate) activeView.onDeactivate();
193
- this.activeViewIndex = newIdx;
194
- this.hoveredTabIndex = null;
195
- const nextView = this.views[this.activeViewIndex];
196
- if (nextView.onActivate) nextView.onActivate();
197
- this.requestRender();
211
+ this.switchTab(newIdx);
198
212
  return;
199
213
  }
200
214
  // Direct tab switching with numbers 1..6 only when NOT typing text in an input
@@ -207,12 +221,7 @@ export class TuiApp {
207
221
  if (!key.ctrl && !key.meta && ["1", "2", "3", "4", "5", "6"].includes(key.name)) {
208
222
  const newIdx = parseInt(key.name, 10) - 1;
209
223
  if (newIdx !== this.activeViewIndex && this.views[newIdx]) {
210
- if (activeView.onDeactivate) activeView.onDeactivate();
211
- this.activeViewIndex = newIdx;
212
- this.hoveredTabIndex = null;
213
- const nextView = this.views[this.activeViewIndex];
214
- if (nextView.onActivate) nextView.onActivate();
215
- this.requestRender();
224
+ this.switchTab(newIdx);
216
225
  return;
217
226
  }
218
227
  }
package/src/tui/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { TuiApp } from "./app.ts";
7
7
 
8
- function parseInitialTab(): number {
8
+ function parseInitialTab(): number | undefined {
9
9
  const args = process.argv.slice(2);
10
10
  const tabArgIdx = args.findIndex((a) => a === "--tab" || a === "-t");
11
11
  if (tabArgIdx !== -1 && args[tabArgIdx + 1]) {
@@ -21,7 +21,7 @@ function parseInitialTab(): number {
21
21
  return parseInt(firstNumeric, 10);
22
22
  }
23
23
 
24
- return 1;
24
+ return undefined;
25
25
  }
26
26
 
27
27
  async function main() {
@@ -13,6 +13,7 @@ import {
13
13
  import { isPlaywrightInitialized } from "../services/playwright.ts";
14
14
  import { getAccountConcurrencySnapshot } from "../core/account-concurrency.ts";
15
15
  import { getRssUsageSnapshot } from "../core/memory-usage.ts";
16
+ import { metrics } from "../core/metrics.ts";
16
17
  import type { ProxyStatusSnapshot } from "./types.ts";
17
18
 
18
19
  export function maskAccountIdentifier(idOrEmail: string): string {
@@ -55,6 +56,7 @@ let lastOnlineState = false;
55
56
  let lastOverallStatus = "offline";
56
57
  let lastServerReadyAccounts: Set<string> | null = null;
57
58
  let lastServerActiveAccounts: Set<string> | null = null;
59
+ let lastMetricsData: any = null;
58
60
  export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
59
61
  const port = config.server?.port || 7936;
60
62
  const configuredHost = config.server?.host;
@@ -79,6 +81,9 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
79
81
  if (Array.isArray(data.activeAccounts)) {
80
82
  lastServerActiveAccounts = new Set(data.activeAccounts);
81
83
  }
84
+ if (data.metrics) {
85
+ lastMetricsData = data.metrics;
86
+ }
82
87
  } else {
83
88
  lastOnlineState = false;
84
89
  lastServerReadyAccounts = null;
@@ -106,8 +111,9 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
106
111
 
107
112
  cachedAccounts = rawAccounts.map((acc) => {
108
113
  const cooldownInfo = getAccountCooldownInfo(acc.id);
109
- const onCooldown = Boolean(cooldownInfo?.onCooldown);
110
- const remainingCooldownMs = cooldownInfo?.remainingMs || 0;
114
+ const onCooldown = Boolean(cooldownInfo?.onCooldown || (acc.cooldown_until && acc.cooldown_until > now));
115
+ const remainingCooldownMs = cooldownInfo?.remainingMs || (acc.cooldown_until && acc.cooldown_until > now ? acc.cooldown_until - now : 0);
116
+ const cooldownReason = cooldownInfo?.reason || acc.cooldown_reason || (onCooldown ? "RateLimited" : null);
111
117
  const headersReady = lastServerReadyAccounts !== null
112
118
  ? lastServerReadyAccounts.has(acc.id)
113
119
  : isAccountHeadersReady(acc.id);
@@ -121,26 +127,37 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
121
127
  cooldownUntil: acc.cooldown_until || null,
122
128
  onCooldown,
123
129
  remainingCooldownMs,
130
+ cooldownReason,
124
131
  headersReady,
125
132
  isInitialized,
126
133
  };
127
134
  });
128
135
  }
129
- const accounts = cachedAccounts;
130
136
  const online = lastOnlineState;
131
137
  const overallStatus = lastOverallStatus;
132
138
 
133
139
  // Concurrency stats
134
140
  let activeStreams = 0;
135
141
  let waitingStreams = 0;
142
+ const concurrencyMap = new Map<string, { active: number; waiting: number; limit: number }>();
136
143
  try {
137
144
  const snapshot = getAccountConcurrencySnapshot();
138
145
  for (const item of snapshot) {
139
146
  activeStreams += item.active;
140
147
  waitingStreams += item.waiting;
148
+ concurrencyMap.set(item.accountId, item);
141
149
  }
142
150
  } catch {}
143
151
 
152
+ // Attach concurrency to accounts
153
+ const accounts = cachedAccounts.map((acc) => {
154
+ const concurrency = concurrencyMap.get(acc.id);
155
+ return {
156
+ ...acc,
157
+ activeStreams: concurrency?.active ?? 0,
158
+ streamLimit: concurrency?.limit ?? config.concurrency.maxStreamsPerAccount,
159
+ };
160
+ });
144
161
  // RAM usage
145
162
  let rssMb = 0;
146
163
  let systemMemoryPct = 0;
@@ -150,6 +167,28 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
150
167
  systemMemoryPct = Math.round(rssSnap.usagePercent * 10) / 10;
151
168
  } catch {}
152
169
 
170
+ const totalReqs = lastMetricsData?.requestsTotal ?? Number(metrics.get("requests.total")?.value ?? 0);
171
+ const totalErrs = lastMetricsData?.requestsErrors ?? Number(metrics.get("requests.errors")?.value ?? 0);
172
+ const successRate = totalReqs > 0 ? Number((((totalReqs - totalErrs) / totalReqs) * 100).toFixed(1)) : 100;
173
+ const latencyAvgMs = lastMetricsData?.latencyAvgMs ?? (() => {
174
+ const hist = metrics.get("latency.request")?.value;
175
+ if (hist && typeof hist === "object" && (hist as any).count > 0) {
176
+ return Math.round((hist as any).sum / (hist as any).count);
177
+ }
178
+ return 0;
179
+ })();
180
+ const deltasCount = lastMetricsData?.deltasCount ?? Number(metrics.get("requests.delta")?.value ?? 0);
181
+ const fullReplaysCount = lastMetricsData?.fullReplaysCount ?? Number(metrics.get("requests.full")?.value ?? 0);
182
+ const totalModes = deltasCount + fullReplaysCount;
183
+ const deltaRatio = totalModes > 0 ? Number(((deltasCount / totalModes) * 100).toFixed(1)) : (deltasCount > 0 ? 100 : 0);
184
+ const toolCallsCount = lastMetricsData?.toolCallsCount ?? Number(metrics.get("toolcalls.total")?.value ?? 0);
185
+ const toolCallsRecovered = lastMetricsData?.toolCallsRecovered ?? Number(metrics.get("toolcalls.recovered")?.value ?? 0);
186
+ const captchasDetected = lastMetricsData?.captchasDetected ?? Number(metrics.get("captcha.challenges.detected")?.value ?? 0);
187
+ const captchasSolved = lastMetricsData?.captchasSolved ?? Number(metrics.get("captcha.solves.succeeded")?.value ?? 0);
188
+ const chatsCleaned = lastMetricsData?.chatsCleaned ?? Number(metrics.get("chats.cleaned")?.value ?? 0);
189
+ const cacheHitRatio = lastMetricsData?.cache?.hitRatio;
190
+ const cacheBytesSaved = lastMetricsData?.cache?.bytesSaved;
191
+
153
192
  return {
154
193
  online,
155
194
  port,
@@ -160,10 +199,25 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
160
199
  systemMemoryPct,
161
200
  activeStreams,
162
201
  waitingStreams,
202
+ metrics: {
203
+ requestsTotal: totalReqs,
204
+ requestsErrors: totalErrs,
205
+ successRate,
206
+ latencyAvgMs,
207
+ deltasCount,
208
+ fullReplaysCount,
209
+ deltaRatio,
210
+ toolCallsCount,
211
+ toolCallsRecovered,
212
+ captchasDetected,
213
+ captchasSolved,
214
+ chatsCleaned,
215
+ cacheHitRatio,
216
+ cacheBytesSaved,
217
+ },
163
218
  accounts,
164
219
  };
165
220
  }
166
-
167
221
  export function resetAllCooldowns(): number {
168
222
  return clearAllAccountCooldowns();
169
223
  }
@@ -31,6 +31,7 @@ export class ServerManager {
31
31
  private intercepted = false;
32
32
  private isTuiRendering = false;
33
33
  private startPromise: Promise<void> | null = null;
34
+ private remoteLogAbort: AbortController | null = null;
34
35
 
35
36
  public static getInstance(): ServerManager {
36
37
  if (!ServerManager.instance) {
@@ -234,6 +235,7 @@ export class ServerManager {
234
235
  "INFO",
235
236
  `✨ [Server] Conectado à instância em execução na porta ${port}`,
236
237
  );
238
+ this.startRemoteLogStream(cleanHost, port);
237
239
  return;
238
240
  }
239
241
  } catch {}
@@ -269,10 +271,53 @@ export class ServerManager {
269
271
  }
270
272
 
271
273
  public async stop(): Promise<void> {
274
+ if (this.remoteLogAbort) {
275
+ this.remoteLogAbort.abort();
276
+ this.remoteLogAbort = null;
277
+ }
272
278
  this.restoreLogs();
273
279
  try {
274
- await stopServer();
275
280
  this.state = "offline";
276
281
  } catch {}
277
282
  }
283
+
284
+ public startRemoteLogStream(host: string, port: number): void {
285
+ if (this.remoteLogAbort) {
286
+ this.remoteLogAbort.abort();
287
+ }
288
+ const abort = new AbortController();
289
+ this.remoteLogAbort = abort;
290
+
291
+ (async () => {
292
+ try {
293
+ const resp = await fetch(`http://${host}:${port}/logs/live`, {
294
+ signal: abort.signal,
295
+ headers: { Accept: "text/event-stream" },
296
+ });
297
+ if (!resp.ok || !resp.body) return;
298
+ const reader = resp.body.getReader();
299
+ const decoder = new TextDecoder();
300
+ let buf = "";
301
+
302
+ while (!abort.signal.aborted) {
303
+ const { done, value } = await reader.read();
304
+ if (done) break;
305
+ buf += decoder.decode(value, { stream: true });
306
+ const lines = buf.split("\n");
307
+ buf = lines.pop() ?? "";
308
+
309
+ for (const line of lines) {
310
+ if (line.startsWith("data: ")) {
311
+ try {
312
+ const data = JSON.parse(line.slice(6));
313
+ if (data && data.message) {
314
+ this.appendLog(data.level || "INFO", data.message);
315
+ }
316
+ } catch {}
317
+ }
318
+ }
319
+ }
320
+ } catch {}
321
+ })();
322
+ }
278
323
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * QwenProxy TUI - User Preferences & Settings Persistence
3
+ * Automatically saves and restores chosen model, reasoning effort, last active tab, and log filters.
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { getTuiSettingsPath } from "../core/paths.ts";
9
+
10
+ export interface TuiSettings {
11
+ lastTab?: number;
12
+ chat?: {
13
+ model?: string;
14
+ effort?: "high" | "medium" | "low";
15
+ };
16
+ logs?: {
17
+ filter?: "all" | "warn" | "error";
18
+ };
19
+ }
20
+
21
+ const defaultSettings: TuiSettings = {
22
+ lastTab: 1,
23
+ chat: {
24
+ model: "qwen3.8-max",
25
+ effort: "high",
26
+ },
27
+ logs: {
28
+ filter: "all",
29
+ },
30
+ };
31
+
32
+ let cachedSettings: TuiSettings | null = null;
33
+
34
+ export function resetTuiSettingsCacheForTests(): void {
35
+ cachedSettings = null;
36
+ }
37
+
38
+ export function loadTuiSettings(): TuiSettings {
39
+ if (cachedSettings) {
40
+ return {
41
+ ...cachedSettings,
42
+ chat: { ...cachedSettings.chat },
43
+ logs: { ...cachedSettings.logs },
44
+ };
45
+ }
46
+
47
+ const filePath = getTuiSettingsPath();
48
+ try {
49
+ if (fs.existsSync(filePath)) {
50
+ const raw = fs.readFileSync(filePath, "utf-8");
51
+ const parsed = JSON.parse(raw);
52
+ const loaded: TuiSettings = {
53
+ ...defaultSettings,
54
+ ...parsed,
55
+ chat: { ...defaultSettings.chat, ...(parsed.chat || {}) },
56
+ logs: { ...defaultSettings.logs, ...(parsed.logs || {}) },
57
+ };
58
+ cachedSettings = loaded;
59
+ return {
60
+ ...loaded,
61
+ chat: { ...loaded.chat },
62
+ logs: { ...loaded.logs },
63
+ };
64
+ }
65
+ } catch {}
66
+
67
+ const finalSettings: TuiSettings = {
68
+ ...defaultSettings,
69
+ chat: { ...defaultSettings.chat },
70
+ logs: { ...defaultSettings.logs },
71
+ };
72
+ cachedSettings = finalSettings;
73
+ return {
74
+ ...finalSettings,
75
+ chat: { ...finalSettings.chat },
76
+ logs: { ...finalSettings.logs },
77
+ };
78
+ }
79
+
80
+ export function saveTuiSettings(updates: Partial<TuiSettings>): void {
81
+ try {
82
+ const current = loadTuiSettings();
83
+ const merged: TuiSettings = {
84
+ ...current,
85
+ ...updates,
86
+ chat: { ...current.chat, ...(updates.chat || {}) },
87
+ logs: { ...current.logs, ...(updates.logs || {}) },
88
+ };
89
+
90
+ cachedSettings = merged;
91
+
92
+ const filePath = getTuiSettingsPath();
93
+ const dir = path.dirname(filePath);
94
+ if (!fs.existsSync(dir)) {
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ }
97
+
98
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf-8");
99
+ } catch {}
100
+ }