qwenproxy-cli 1.2.2 → 1.2.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/package.json +3 -3
- package/src/routes/anthropic/index.ts +26 -1
- package/src/routes/chat/helpers.ts +23 -0
- package/src/routes/chat/streaming.ts +44 -13
- package/src/routes/chat/validation.ts +58 -8
- package/src/routes/responses/index.ts +13 -11
- package/src/services/playwright.ts +17 -1
- package/src/services/qwen.ts +1 -1
- package/src/tools/parser.ts +35 -13
- package/src/tui/theme.ts +87 -55
- package/src/tui/views/logs-view.ts +99 -98
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.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": {
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"reset": "npx tsx src/reset-cooldowns.ts",
|
|
27
27
|
"purge": "npx tsx src/delete-chats.ts",
|
|
28
28
|
"test": "npm run test:mock && npm run test:live",
|
|
29
|
-
"test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts",
|
|
30
|
-
"test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts",
|
|
29
|
+
"test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts",
|
|
30
|
+
"test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts",
|
|
31
31
|
"test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
|
|
32
32
|
"benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
@@ -97,6 +97,18 @@ app.post("/v1/messages", async (c) => {
|
|
|
97
97
|
try {
|
|
98
98
|
// 3. Translate Anthropic request to internal OpenAI format
|
|
99
99
|
const openaiRequest = translateAnthropicToOpenAI(body);
|
|
100
|
+
const claudeSessionId =
|
|
101
|
+
c.req.header("x-claude-code-session-id") ||
|
|
102
|
+
(typeof body.metadata?.user_id === "string" && body.metadata.user_id.includes('"session_id":')
|
|
103
|
+
? (() => {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(body.metadata.user_id as string);
|
|
106
|
+
return typeof parsed?.session_id === "string" ? parsed.session_id : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
})()
|
|
111
|
+
: undefined);
|
|
100
112
|
|
|
101
113
|
const dispatchToChat = (streamMode: boolean) =>
|
|
102
114
|
fetch(`http://127.0.0.1:${config.server.port}/v1/chat/completions`, {
|
|
@@ -108,9 +120,11 @@ app.post("/v1/messages", async (c) => {
|
|
|
108
120
|
...(c.req.header("x-qwenproxy-chat-mode")
|
|
109
121
|
? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
|
|
110
122
|
: {}),
|
|
123
|
+
...(claudeSessionId ? { "x-session-id": claudeSessionId } : {}),
|
|
111
124
|
},
|
|
112
125
|
body: JSON.stringify({
|
|
113
126
|
...openaiRequest,
|
|
127
|
+
...(claudeSessionId ? { session_id: claudeSessionId } : {}),
|
|
114
128
|
stream: streamMode,
|
|
115
129
|
...(streamMode ? { stream_options: { include_usage: true } } : {}),
|
|
116
130
|
}),
|
|
@@ -247,7 +261,18 @@ app.post("/v1/messages", async (c) => {
|
|
|
247
261
|
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
248
262
|
);
|
|
249
263
|
} catch (error: any) {
|
|
250
|
-
|
|
264
|
+
const isClientAbort =
|
|
265
|
+
c.req.raw.signal.aborted ||
|
|
266
|
+
error?.name === "AbortError" ||
|
|
267
|
+
error?.code === "ECONNRESET" ||
|
|
268
|
+
(typeof error?.message === "string" &&
|
|
269
|
+
(error.message.includes("prematurely closed") ||
|
|
270
|
+
error.message.includes("aborted") ||
|
|
271
|
+
error.message.includes("canceled") ||
|
|
272
|
+
error.message.includes("cancelled")));
|
|
273
|
+
if (!isClientAbort) {
|
|
274
|
+
console.error("❌ [Anthropic] Stream error:", error?.message || error);
|
|
275
|
+
}
|
|
251
276
|
try {
|
|
252
277
|
await write(
|
|
253
278
|
`event: error\ndata: ${JSON.stringify({
|
|
@@ -113,6 +113,29 @@ export function formatThinkingSummaryContent(delta: any): string {
|
|
|
113
113
|
return sections.join("\n\n");
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Matches thinking phases verified in upstream Qwen Web HAR:
|
|
118
|
+
* - "thinking_summary": structured thinking in Qwen Max / Plus
|
|
119
|
+
* - "think": incremental thinking deltas in Qwen Omni
|
|
120
|
+
*/
|
|
121
|
+
export function isThinkingPhase(phase: unknown): boolean {
|
|
122
|
+
return phase === "think" || phase === "thinking_summary";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract thinking/reasoning text from a delta chunk, supporting both
|
|
127
|
+
* structured summaries (summary_title/summary_thought) and direct content deltas.
|
|
128
|
+
*/
|
|
129
|
+
export function extractThinkingContent(delta: any): string {
|
|
130
|
+
if (
|
|
131
|
+
Array.isArray(delta?.extra?.summary_title?.content) ||
|
|
132
|
+
Array.isArray(delta?.extra?.summary_thought?.content)
|
|
133
|
+
) {
|
|
134
|
+
return formatThinkingSummaryContent(delta);
|
|
135
|
+
}
|
|
136
|
+
return typeof delta?.content === "string" ? delta.content : "";
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
export function isAbortError(err: unknown): boolean {
|
|
117
140
|
if (err instanceof DOMException) {
|
|
118
141
|
return err.name === "AbortError";
|
|
@@ -67,6 +67,8 @@ import {
|
|
|
67
67
|
} from "../../services/context-meter.ts";
|
|
68
68
|
import {
|
|
69
69
|
getIncrementalDelta,
|
|
70
|
+
isThinkingPhase,
|
|
71
|
+
extractThinkingContent,
|
|
70
72
|
formatThinkingSummaryContent,
|
|
71
73
|
shouldSuppressStreamAbort,
|
|
72
74
|
isAbortError,
|
|
@@ -436,9 +438,9 @@ export async function processNonStreamingResponse(
|
|
|
436
438
|
) {
|
|
437
439
|
const delta = chunk.choices[0].delta;
|
|
438
440
|
|
|
439
|
-
if (delta.phase
|
|
441
|
+
if (isThinkingPhase(delta.phase)) {
|
|
440
442
|
isThinkingChunk = true;
|
|
441
|
-
const formattedSummary =
|
|
443
|
+
const formattedSummary = extractThinkingContent(delta);
|
|
442
444
|
if (formattedSummary) {
|
|
443
445
|
const result = getIncrementalDelta(
|
|
444
446
|
lastThinkingSummary,
|
|
@@ -1483,6 +1485,7 @@ export async function processStreamingResponse(
|
|
|
1483
1485
|
}
|
|
1484
1486
|
|
|
1485
1487
|
const previousUiSessionId = currentUiSessionId;
|
|
1488
|
+
const previousAccountId = currentAccountId;
|
|
1486
1489
|
currentAccountId = newStreamResult.activeAccountId;
|
|
1487
1490
|
currentUiSessionId = newStreamResult.uiSessionId;
|
|
1488
1491
|
retryContext.releaseAccountLease =
|
|
@@ -1515,9 +1518,18 @@ export async function processStreamingResponse(
|
|
|
1515
1518
|
});
|
|
1516
1519
|
}
|
|
1517
1520
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
+
const switched =
|
|
1522
|
+
previousUiSessionId !== currentUiSessionId ||
|
|
1523
|
+
previousAccountId !== currentAccountId;
|
|
1524
|
+
if (switched) {
|
|
1525
|
+
console.log(
|
|
1526
|
+
`🔄 [Chat] Stream recovery switched account | old=${previousUiSessionId.substring(0, 12)} | new=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
|
|
1527
|
+
);
|
|
1528
|
+
} else {
|
|
1529
|
+
console.log(
|
|
1530
|
+
`🔄 [Chat] Stream recovery resumed | chat=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1521
1533
|
reader = newStreamResult.stream.getReader();
|
|
1522
1534
|
activeReader = reader;
|
|
1523
1535
|
return true;
|
|
@@ -1737,9 +1749,9 @@ export async function processStreamingResponse(
|
|
|
1737
1749
|
break; // Exit the for loop; the while check leaves the read loop
|
|
1738
1750
|
}
|
|
1739
1751
|
|
|
1740
|
-
if (delta.phase
|
|
1752
|
+
if (isThinkingPhase(delta.phase)) {
|
|
1741
1753
|
isThinkingChunk = true;
|
|
1742
|
-
const formattedSummary =
|
|
1754
|
+
const formattedSummary = extractThinkingContent(delta);
|
|
1743
1755
|
if (formattedSummary) {
|
|
1744
1756
|
const result = getIncrementalDelta(
|
|
1745
1757
|
lastThinkingSummary,
|
|
@@ -2254,9 +2266,9 @@ export async function processStreamingResponse(
|
|
|
2254
2266
|
let foundStr = false;
|
|
2255
2267
|
let isThinkingChunk = false;
|
|
2256
2268
|
|
|
2257
|
-
if (delta.phase
|
|
2269
|
+
if (isThinkingPhase(delta.phase)) {
|
|
2258
2270
|
isThinkingChunk = true;
|
|
2259
|
-
const formattedSummary =
|
|
2271
|
+
const formattedSummary = extractThinkingContent(delta);
|
|
2260
2272
|
if (formattedSummary) {
|
|
2261
2273
|
const result = getIncrementalDelta(
|
|
2262
2274
|
lastThinkingSummary,
|
|
@@ -2655,10 +2667,29 @@ export async function processStreamingResponse(
|
|
|
2655
2667
|
} else {
|
|
2656
2668
|
logger.error("[Chat] Stream callback error", errorDetails);
|
|
2657
2669
|
}
|
|
2658
|
-
|
|
2659
|
-
//
|
|
2660
|
-
//
|
|
2670
|
+
// The HTTP response is already committed at this point.
|
|
2671
|
+
// 1. Emit an explicit assistant message delta so CLI/TUI clients that only
|
|
2672
|
+
// listen for choices[].delta.content (e.g. OpenCode, Claude Code, etc.)
|
|
2673
|
+
// render the error visibly in the chat instead of appearing blank/frozen.
|
|
2674
|
+
// 2. Emit a terminal OpenAI-compatible SSE error event + [DONE].
|
|
2661
2675
|
try {
|
|
2676
|
+
const userFriendlyNotice = `\n\n⚠️ **[Qwen Security / Erro]** ${err.message || "A resposta foi interrompida pelo Qwen."}\n\n`;
|
|
2677
|
+
const errorDelta = {
|
|
2678
|
+
id: completionId,
|
|
2679
|
+
object: "chat.completion.chunk",
|
|
2680
|
+
created: Math.floor(Date.now() / 1000),
|
|
2681
|
+
model: body.model,
|
|
2682
|
+
choices: [
|
|
2683
|
+
{
|
|
2684
|
+
index: 0,
|
|
2685
|
+
delta: { content: userFriendlyNotice },
|
|
2686
|
+
logprobs: null,
|
|
2687
|
+
finish_reason: "stop",
|
|
2688
|
+
},
|
|
2689
|
+
],
|
|
2690
|
+
};
|
|
2691
|
+
|
|
2692
|
+
await errorStream.write(`data: ${JSON.stringify(errorDelta)}\n\n`);
|
|
2662
2693
|
await errorStream.write(
|
|
2663
2694
|
`data: ${JSON.stringify({
|
|
2664
2695
|
error: {
|
|
@@ -2668,7 +2699,7 @@ export async function processStreamingResponse(
|
|
|
2668
2699
|
},
|
|
2669
2700
|
})}\n\ndata: [DONE]\n\n`,
|
|
2670
2701
|
);
|
|
2671
|
-
} catch
|
|
2702
|
+
} catch {
|
|
2672
2703
|
// Stream already closed — client already disconnected or the stream
|
|
2673
2704
|
// was cancelled. Nothing more we can do.
|
|
2674
2705
|
}
|
|
@@ -43,14 +43,7 @@ export async function parseRequestBody(c: Context): Promise<ParsedRequest> {
|
|
|
43
43
|
const body: OpenAIRequest = await c.req.json();
|
|
44
44
|
logIncomingChatRequest(c, body);
|
|
45
45
|
const isStream = body.stream ?? false;
|
|
46
|
-
const conversationKey =
|
|
47
|
-
typeof body.session_id === "string" && body.session_id.trim().length > 0
|
|
48
|
-
? body.session_id.trim()
|
|
49
|
-
: typeof body.conversation_id === "string" &&
|
|
50
|
-
body.conversation_id.trim().length > 0
|
|
51
|
-
? body.conversation_id.trim()
|
|
52
|
-
: null;
|
|
53
|
-
|
|
46
|
+
const conversationKey = extractExplicitConversationKey(c, body);
|
|
54
47
|
const messages = body.messages || [];
|
|
55
48
|
let uploadHeaders: Record<string, string> | null = null;
|
|
56
49
|
|
|
@@ -328,6 +321,63 @@ function contentLength(value: unknown): number {
|
|
|
328
321
|
return 0;
|
|
329
322
|
}
|
|
330
323
|
|
|
324
|
+
export function extractExplicitConversationKey(
|
|
325
|
+
c: Context,
|
|
326
|
+
body: OpenAIRequest,
|
|
327
|
+
): string | null {
|
|
328
|
+
const b = body as any;
|
|
329
|
+
|
|
330
|
+
// 1. Direct body properties (OpenAI-compatible extensions)
|
|
331
|
+
if (typeof b?.session_id === "string" && b.session_id.trim().length > 0) {
|
|
332
|
+
return b.session_id.trim();
|
|
333
|
+
}
|
|
334
|
+
if (typeof b?.conversation_id === "string" && b.conversation_id.trim().length > 0) {
|
|
335
|
+
return b.conversation_id.trim();
|
|
336
|
+
}
|
|
337
|
+
if (typeof b?.chat_id === "string" && b.chat_id.trim().length > 0) {
|
|
338
|
+
return b.chat_id.trim();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 2. HTTP headers sent natively by coding agents
|
|
342
|
+
const headerCandidates = [
|
|
343
|
+
c.req.header("x-session-id"), // OpenCode, Cline, AI-SDK, custom
|
|
344
|
+
c.req.header("x-session-affinity"), // OpenCode / AI-SDK
|
|
345
|
+
c.req.header("session-id"), // OpenAI Codex CLI
|
|
346
|
+
c.req.header("x-claude-code-session-id"), // Claude Code CLI
|
|
347
|
+
c.req.header("x-conversation-id"), // Standard agents
|
|
348
|
+
c.req.header("conversation-id"), // Standard agents
|
|
349
|
+
c.req.header("x-client-request-id"), // Client fallback ID
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
for (const candidate of headerCandidates) {
|
|
353
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
354
|
+
return candidate.trim();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 3. Metadata payloads (Claude Code embeds session_id in user_id metadata JSON)
|
|
359
|
+
if (b?.metadata && typeof b.metadata === "object") {
|
|
360
|
+
if (typeof b.metadata.session_id === "string" && b.metadata.session_id.trim().length > 0) {
|
|
361
|
+
return b.metadata.session_id.trim();
|
|
362
|
+
}
|
|
363
|
+
if (typeof b.metadata.user_id === "string" && b.metadata.user_id.includes('"session_id":')) {
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(b.metadata.user_id);
|
|
366
|
+
if (typeof parsed?.session_id === "string" && parsed.session_id.trim().length > 0) {
|
|
367
|
+
return parsed.session_id.trim();
|
|
368
|
+
}
|
|
369
|
+
} catch {}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 4. Prompt cache key (used by Codex for session consistency)
|
|
374
|
+
if (typeof b?.prompt_cache_key === "string" && b.prompt_cache_key.trim().length > 0) {
|
|
375
|
+
return b.prompt_cache_key.trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
331
381
|
function logIncomingChatRequest(c: Context, body: OpenAIRequest): void {
|
|
332
382
|
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
333
383
|
const tools = Array.isArray((body as any).tools) ? (body as any).tools : [];
|
|
@@ -65,11 +65,16 @@ app.post("/v1/responses", async (c) => {
|
|
|
65
65
|
historyMessages = history;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const responsesSessionId =
|
|
69
|
+
c.req.header("session-id") ||
|
|
70
|
+
c.req.header("x-session-id") ||
|
|
71
|
+
c.req.header("x-client-request-id") ||
|
|
72
|
+
(req as any).prompt_cache_key ||
|
|
73
|
+
(typeof (body as any)?.session_id === "string" ? (body as any).session_id : undefined);
|
|
68
74
|
// Convert to Chat Completions format
|
|
69
75
|
const chatRequest = responsesToChatCompletions(req, historyMessages);
|
|
70
76
|
|
|
71
77
|
if (isStream) {
|
|
72
|
-
// ============ STREAMING MODE ============
|
|
73
78
|
const socket =
|
|
74
79
|
(c.env as any)?.incoming?.socket || (c.req.raw as any)?.socket;
|
|
75
80
|
if (socket && typeof socket.setNoDelay === "function") {
|
|
@@ -139,9 +144,11 @@ app.post("/v1/responses", async (c) => {
|
|
|
139
144
|
...(c.req.header("x-qwenproxy-chat-mode")
|
|
140
145
|
? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
|
|
141
146
|
: {}),
|
|
147
|
+
...(responsesSessionId ? { "session-id": responsesSessionId, "x-session-id": responsesSessionId } : {}),
|
|
142
148
|
},
|
|
143
149
|
body: JSON.stringify({
|
|
144
150
|
...chatRequest,
|
|
151
|
+
...(responsesSessionId ? { session_id: responsesSessionId } : {}),
|
|
145
152
|
stream: true,
|
|
146
153
|
stream_options: { include_usage: true },
|
|
147
154
|
}),
|
|
@@ -306,19 +313,14 @@ app.post("/v1/responses", async (c) => {
|
|
|
306
313
|
...(c.req.header("x-qwenproxy-chat-mode")
|
|
307
314
|
? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
|
|
308
315
|
: {}),
|
|
316
|
+
...(responsesSessionId ? { "session-id": responsesSessionId, "x-session-id": responsesSessionId } : {}),
|
|
309
317
|
},
|
|
310
|
-
body: JSON.stringify(
|
|
318
|
+
body: JSON.stringify({
|
|
319
|
+
...chatRequest,
|
|
320
|
+
...(responsesSessionId ? { session_id: responsesSessionId } : {}),
|
|
321
|
+
}),
|
|
311
322
|
},
|
|
312
323
|
);
|
|
313
|
-
|
|
314
|
-
if (!response.ok) {
|
|
315
|
-
const errorText = await response.text();
|
|
316
|
-
console.error(
|
|
317
|
-
`[Responses] Upstream error: ${response.status} ${errorText}`,
|
|
318
|
-
);
|
|
319
|
-
return responsesError(c, "api_error", "Upstream service error", 502);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
324
|
const chatResponse = await response.json();
|
|
323
325
|
const responsesResponse = chatCompletionsToResponses(
|
|
324
326
|
chatResponse,
|
|
@@ -61,7 +61,7 @@ import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
|
|
|
61
61
|
// acyclic, while the reverse direction would drag the browser layer into core.
|
|
62
62
|
import { hasActiveAccountLease } from "../core/account-concurrency.ts";
|
|
63
63
|
import { config } from "../core/config.ts";
|
|
64
|
-
import { maskEmail } from "../core/logger.ts";
|
|
64
|
+
import { maskEmail, logger } from "../core/logger.ts";
|
|
65
65
|
import { Mutex } from "../core/mutex.ts";
|
|
66
66
|
import {
|
|
67
67
|
markAccountHeadersReady,
|
|
@@ -184,6 +184,22 @@ async function recoverStuckAccountMutex(
|
|
|
184
184
|
// account be initialized again instead of remaining permanently wedged.
|
|
185
185
|
if (accountMutexes.get(accountId) !== mutex) return;
|
|
186
186
|
|
|
187
|
+
const lockState = mutex.state();
|
|
188
|
+
// A lock is only considered stuck if it has been held for at least PLAYWRIGHT_MUTEX_WAIT_MS (60s).
|
|
189
|
+
// A waiter with a short timeout (e.g. 5s) timing out does NOT mean the lock holder is stuck!
|
|
190
|
+
// Furthermore, never nuke an account while it is initializing (init: takes 20-30s)
|
|
191
|
+
// or while it is actively serving a stream to a user.
|
|
192
|
+
if (
|
|
193
|
+
lockState.heldForMs < PLAYWRIGHT_MUTEX_WAIT_MS ||
|
|
194
|
+
lockState.heldBy.startsWith("init:") ||
|
|
195
|
+
isAccountServingStream(accountId)
|
|
196
|
+
) {
|
|
197
|
+
logger.warn(
|
|
198
|
+
`[Playwright] Skipping destructive mutex recovery | account=${accountId} | heldBy=${lockState.heldBy} | heldFor=${lockState.heldForMs}ms | limit=${PLAYWRIGHT_MUTEX_WAIT_MS}ms | servingStream=${isAccountServingStream(accountId)}`,
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
console.warn(
|
|
188
204
|
`[Playwright] Recovering stuck account mutex | account=${accountId} | key=${key}`,
|
|
189
205
|
);
|
package/src/services/qwen.ts
CHANGED
package/src/tools/parser.ts
CHANGED
|
@@ -2453,18 +2453,24 @@ export class StreamingToolParser {
|
|
|
2453
2453
|
recoveryAttempts,
|
|
2454
2454
|
});
|
|
2455
2455
|
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2456
|
+
if (isToolcallDebugEnabled()) {
|
|
2457
|
+
logger.warn(
|
|
2458
|
+
`[parser] Dropping malformed tool call (${t.length} chars): ${t.substring(0, 80).replace(/\n/g, " ")}...`,
|
|
2459
|
+
{
|
|
2460
|
+
toolName: droppedToolName,
|
|
2461
|
+
category: "malformed",
|
|
2462
|
+
contentLength: t.length,
|
|
2463
|
+
content: t.substring(0, 2000),
|
|
2464
|
+
failureReason: "all recovery stages failed to produce valid JSON",
|
|
2465
|
+
recoveryAttempts,
|
|
2466
|
+
declaredTools: [...this.declaredToolNames].slice(0, 10),
|
|
2467
|
+
},
|
|
2468
|
+
);
|
|
2469
|
+
} else {
|
|
2470
|
+
logger.warn(
|
|
2471
|
+
`[parser] Dropping malformed tool call (${t.length} chars)${droppedToolName ? ` [${droppedToolName}]` : ""}: ${t.substring(0, 80).replace(/\n/g, " ")}...`,
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2468
2474
|
if (
|
|
2469
2475
|
this.emittedToolCallCount === 0 &&
|
|
2470
2476
|
this.pendingLeadIn.trim().length > 0
|
|
@@ -3018,8 +3024,24 @@ export class StreamingToolParser {
|
|
|
3018
3024
|
private parseToolCall(parsed: any): ParsedToolCall | null {
|
|
3019
3025
|
if (!parsed || typeof parsed !== "object") return null;
|
|
3020
3026
|
|
|
3021
|
-
|
|
3027
|
+
let name =
|
|
3022
3028
|
parsed.name || parsed.function?.name || parsed.tool_name || parsed.tool;
|
|
3029
|
+
if (!name || typeof name !== "string" || name.length === 0) {
|
|
3030
|
+
const candidateArgs =
|
|
3031
|
+
parsed.arguments ||
|
|
3032
|
+
parsed.function?.arguments ||
|
|
3033
|
+
parsed.args ||
|
|
3034
|
+
parsed.parameters ||
|
|
3035
|
+
parsed.input ||
|
|
3036
|
+
parsed;
|
|
3037
|
+
const parsedCandidateArgs =
|
|
3038
|
+
typeof candidateArgs === "string"
|
|
3039
|
+
? parseJsonishString(candidateArgs) ?? {}
|
|
3040
|
+
: typeof candidateArgs === "object" && candidateArgs !== null
|
|
3041
|
+
? candidateArgs
|
|
3042
|
+
: {};
|
|
3043
|
+
name = inferToolNameFromParameters(parsedCandidateArgs, this.tools);
|
|
3044
|
+
}
|
|
3023
3045
|
if (!name || typeof name !== "string" || name.length === 0) return null;
|
|
3024
3046
|
|
|
3025
3047
|
// Drop hallucinated tool calls where the model split a value vertically
|
package/src/tui/theme.ts
CHANGED
|
@@ -191,67 +191,81 @@ export function stripAnsi(str: string): string {
|
|
|
191
191
|
}
|
|
192
192
|
const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
193
193
|
|
|
194
|
+
function singleGraphemeWidth(segment: string): number {
|
|
195
|
+
const code = segment.codePointAt(0) || 0;
|
|
196
|
+
// Standalone zero-width characters (variation selectors, zero-width space/joiner)
|
|
197
|
+
if (
|
|
198
|
+
segment === "\u200b" ||
|
|
199
|
+
segment === "\u200c" ||
|
|
200
|
+
segment === "\u200d" ||
|
|
201
|
+
(code >= 0xfe00 && code <= 0xfe0f && segment.length === 1)
|
|
202
|
+
) {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
// Graphemes containing variation selector 16 (emoji presentation)
|
|
206
|
+
if (segment.includes("\ufe0f")) {
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
// Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⏱, ⚡, ✅, ❌, ✨, ☕, ⚙)
|
|
210
|
+
const isBmpEmoji =
|
|
211
|
+
code === 0x26a0 || // ⚠️ (WARNING SIGN)
|
|
212
|
+
code === 0x23f1 || // ⏱ (STOPWATCH)
|
|
213
|
+
code === 0x23f0 || // ⏰
|
|
214
|
+
code === 0x23f3 || // ⏳
|
|
215
|
+
code === 0x231a || // ⌚
|
|
216
|
+
code === 0x231b || // ⌛
|
|
217
|
+
code === 0x2705 || // ✅
|
|
218
|
+
code === 0x2728 || // ✨
|
|
219
|
+
code === 0x274c || // ❌
|
|
220
|
+
code === 0x274e || // ❎
|
|
221
|
+
code === 0x2753 || // ❓
|
|
222
|
+
code === 0x2757 || // ❗
|
|
223
|
+
code === 0x2b50 || // ⭐
|
|
224
|
+
code === 0x2b55 || // ⭕
|
|
225
|
+
code === 0x26a1 || // ⚡
|
|
226
|
+
code === 0x2615 || // ☕
|
|
227
|
+
code === 0x2699 || // ⚙
|
|
228
|
+
code === 0x2709; // ✉
|
|
229
|
+
|
|
230
|
+
// Common emoji and CJK full-width ranges (SMP Emojis 0x1f300 - 0x1faff)
|
|
231
|
+
if (
|
|
232
|
+
isBmpEmoji ||
|
|
233
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
234
|
+
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
235
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
236
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
237
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
238
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
239
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
240
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
241
|
+
(code >= 0x1f300 && code <= 0x1faff)
|
|
242
|
+
) {
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
194
248
|
/**
|
|
195
249
|
* Computes visual display width of string, accounting for wide characters,
|
|
196
250
|
* grapheme clusters, variation selectors (VS16), and terminal-wide emojis.
|
|
197
251
|
*/
|
|
198
252
|
export function stringWidth(str: string): number {
|
|
253
|
+
if (!str) return 0;
|
|
254
|
+
// Fast path for pure ASCII strings without ANSI escapes
|
|
255
|
+
let isAscii = true;
|
|
256
|
+
for (let i = 0; i < str.length; i++) {
|
|
257
|
+
const c = str.charCodeAt(i);
|
|
258
|
+
if (c < 32 || c > 126) {
|
|
259
|
+
isAscii = false;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (isAscii) return str.length;
|
|
264
|
+
|
|
199
265
|
const clean = stripAnsi(str).replace(/\t/g, " ").replace(/\r/g, "");
|
|
200
266
|
let width = 0;
|
|
201
267
|
for (const { segment } of graphemeSegmenter.segment(clean)) {
|
|
202
|
-
|
|
203
|
-
// Standalone zero-width characters (variation selectors, zero-width space/joiner)
|
|
204
|
-
if (
|
|
205
|
-
segment === "\u200b" ||
|
|
206
|
-
segment === "\u200c" ||
|
|
207
|
-
segment === "\u200d" ||
|
|
208
|
-
(code >= 0xfe00 && code <= 0xfe0f && segment.length === 1)
|
|
209
|
-
) {
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
// Graphemes containing variation selector 16 (emoji presentation)
|
|
213
|
-
if (segment.includes("\ufe0f")) {
|
|
214
|
-
width += 2;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
// Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⏱, ⚡, ✅, ❌, ✨, ☕, ⚙)
|
|
218
|
-
const isBmpEmoji =
|
|
219
|
-
code === 0x26a0 || // ⚠️ (WARNING SIGN)
|
|
220
|
-
code === 0x23f1 || // ⏱ (STOPWATCH)
|
|
221
|
-
code === 0x23f0 || // ⏰
|
|
222
|
-
code === 0x23f3 || // ⏳
|
|
223
|
-
code === 0x231a || // ⌚
|
|
224
|
-
code === 0x231b || // ⌛
|
|
225
|
-
code === 0x2705 || // ✅
|
|
226
|
-
code === 0x2728 || // ✨
|
|
227
|
-
code === 0x274c || // ❌
|
|
228
|
-
code === 0x274e || // ❎
|
|
229
|
-
code === 0x2753 || // ❓
|
|
230
|
-
code === 0x2757 || // ❗
|
|
231
|
-
code === 0x2b50 || // ⭐
|
|
232
|
-
code === 0x2b55 || // ⭕
|
|
233
|
-
code === 0x26a1 || // ⚡
|
|
234
|
-
code === 0x2615 || // ☕
|
|
235
|
-
code === 0x2699 || // ⚙
|
|
236
|
-
code === 0x2709; // ✉
|
|
237
|
-
|
|
238
|
-
// Common emoji and CJK full-width ranges (SMP Emojis 0x1f300 - 0x1faff)
|
|
239
|
-
if (
|
|
240
|
-
isBmpEmoji ||
|
|
241
|
-
(code >= 0x1100 && code <= 0x115f) ||
|
|
242
|
-
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
243
|
-
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
244
|
-
(code >= 0xf900 && code <= 0xfaff) ||
|
|
245
|
-
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
246
|
-
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
247
|
-
(code >= 0xff01 && code <= 0xff60) ||
|
|
248
|
-
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
249
|
-
(code >= 0x1f300 && code <= 0x1faff)
|
|
250
|
-
) {
|
|
251
|
-
width += 2;
|
|
252
|
-
} else {
|
|
253
|
-
width += 1;
|
|
254
|
-
}
|
|
268
|
+
width += singleGraphemeWidth(segment);
|
|
255
269
|
}
|
|
256
270
|
return width;
|
|
257
271
|
}
|
|
@@ -261,8 +275,26 @@ export function stringWidth(str: string): number {
|
|
|
261
275
|
*/
|
|
262
276
|
export function truncate(str: string, maxWidth: number, ellipsis = "…"): string {
|
|
263
277
|
if (maxWidth <= 0) return "";
|
|
278
|
+
if (!str) return "";
|
|
279
|
+
|
|
280
|
+
// Fast path for pure ASCII strings
|
|
281
|
+
let isAscii = true;
|
|
282
|
+
for (let i = 0; i < str.length; i++) {
|
|
283
|
+
const c = str.charCodeAt(i);
|
|
284
|
+
if (c < 32 || c > 126) {
|
|
285
|
+
isAscii = false;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (isAscii) {
|
|
290
|
+
if (str.length <= maxWidth) return str;
|
|
291
|
+
const targetW = Math.max(0, maxWidth - 1);
|
|
292
|
+
return str.slice(0, targetW) + ellipsis;
|
|
293
|
+
}
|
|
294
|
+
|
|
264
295
|
const clean = stripAnsi(str).replace(/\t/g, " ").replace(/\r/g, "");
|
|
265
|
-
|
|
296
|
+
const cleanW = stringWidth(clean);
|
|
297
|
+
if (cleanW <= maxWidth) return str;
|
|
266
298
|
|
|
267
299
|
const ellipsisW = stringWidth(ellipsis);
|
|
268
300
|
const targetW = Math.max(0, maxWidth - ellipsisW);
|
|
@@ -270,7 +302,7 @@ export function truncate(str: string, maxWidth: number, ellipsis = "…"): strin
|
|
|
270
302
|
let currentW = 0;
|
|
271
303
|
let result = "";
|
|
272
304
|
for (const { segment } of graphemeSegmenter.segment(clean)) {
|
|
273
|
-
const segW =
|
|
305
|
+
const segW = singleGraphemeWidth(segment);
|
|
274
306
|
if (currentW + segW > targetW) break;
|
|
275
307
|
currentW += segW;
|
|
276
308
|
result += segment;
|
|
@@ -89,58 +89,56 @@ export class LogsView implements TuiView {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
public handleKey(key: KeyEvent): boolean | void {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// Mouse hover on chips (accepts rows 3 to 6 for generous vertical target)
|
|
96
|
-
if (key.name === "hover" && key.mouse) {
|
|
92
|
+
// Mouse hover or click on chips (only compute chips when mouse is on chip rows 3 to 6)
|
|
93
|
+
if ((key.name === "hover" || key.name === "click") && key.mouse) {
|
|
97
94
|
if (key.mouse.row >= 3 && key.mouse.row <= 6) {
|
|
95
|
+
const rawCount = ServerManager.getInstance().getLogEntries(this.filter).length;
|
|
96
|
+
const { chips } = this.getChips(rawCount);
|
|
98
97
|
const col = key.mouse.col;
|
|
99
|
-
let target: "all" | "warn" | "error" | "copy" | "clear" | null = null;
|
|
100
|
-
for (const c of chips) {
|
|
101
|
-
if (col >= c.startCol && col <= c.endCol) {
|
|
102
|
-
target = c.id;
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (this.hoveredChip !== target) {
|
|
107
|
-
this.hoveredChip = target;
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
} else if (this.hoveredChip !== null) {
|
|
111
|
-
this.hoveredChip = null;
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
98
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return true;
|
|
99
|
+
if (key.name === "hover") {
|
|
100
|
+
let target: "all" | "warn" | "error" | "copy" | "clear" | null = null;
|
|
101
|
+
for (const c of chips) {
|
|
102
|
+
if (col >= c.startCol && col <= c.endCol) {
|
|
103
|
+
target = c.id;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
124
106
|
}
|
|
125
|
-
if (
|
|
126
|
-
this.
|
|
107
|
+
if (this.hoveredChip !== target) {
|
|
108
|
+
this.hoveredChip = target;
|
|
127
109
|
return true;
|
|
128
110
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
111
|
+
} else if (key.name === "click") {
|
|
112
|
+
for (const c of chips) {
|
|
113
|
+
if (col >= c.startCol && col <= c.endCol) {
|
|
114
|
+
if (c.id === "all") {
|
|
115
|
+
this.setFilter("all");
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (c.id === "warn") {
|
|
119
|
+
this.setFilter("warn");
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (c.id === "error") {
|
|
123
|
+
this.setFilter("error");
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (c.id === "copy") {
|
|
127
|
+
this.copyLogs();
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (c.id === "clear") {
|
|
131
|
+
ServerManager.getInstance().clearLogs();
|
|
132
|
+
this.scrollOffset = 0;
|
|
133
|
+
this.selectedLogIndex = null;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
142
137
|
}
|
|
143
138
|
}
|
|
139
|
+
} else if (this.hoveredChip !== null) {
|
|
140
|
+
this.hoveredChip = null;
|
|
141
|
+
return true;
|
|
144
142
|
}
|
|
145
143
|
}
|
|
146
144
|
|
|
@@ -351,39 +349,11 @@ export class LogsView implements TuiView {
|
|
|
351
349
|
? theme.bgHover(" [ C ] Limpar ")
|
|
352
350
|
: theme.muted(" [ C ] Limpar ");
|
|
353
351
|
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
if (rawEntries.length === 0) {
|
|
357
|
-
formattedLines.push("");
|
|
358
|
-
formattedLines.push(
|
|
359
|
-
theme.muted(` Nenhum log registrado para o filtro atual [${this.filter}].`),
|
|
360
|
-
);
|
|
361
|
-
formattedLines.push(
|
|
362
|
-
theme.muted(" Eventos de inicialização, requisições e alertas do proxy aparecerão aqui."),
|
|
363
|
-
);
|
|
364
|
-
} else {
|
|
365
|
-
for (const entry of rawEntries) {
|
|
366
|
-
if (!entry || !entry.message || !entry.message.trim()) continue;
|
|
367
|
-
let tag = theme.dim("[INFO]");
|
|
368
|
-
if (entry.level === "WARN") {
|
|
369
|
-
tag = theme.yellow("[WARN]");
|
|
370
|
-
} else if (entry.level === "ERROR") {
|
|
371
|
-
tag = theme.red("[ERR]");
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const prefix = `${theme.dim(entry.time)} ${tag} `;
|
|
375
|
-
const prefixW = stringWidth(prefix);
|
|
376
|
-
const maxMsgW = Math.max(20, innerW - prefixW - 4);
|
|
377
|
-
const truncatedMsg = truncate(entry.message, maxMsgW);
|
|
378
|
-
formattedLines.push(`${prefix}${truncatedMsg}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
this.lastTotalCount = formattedLines.length;
|
|
352
|
+
const total = rawEntries.length;
|
|
353
|
+
this.lastTotalCount = total;
|
|
383
354
|
|
|
384
355
|
// Scroll Window with top margin breathing room (2 rows reserved at the top)
|
|
385
356
|
const visibleCapacity = Math.max(1, contentH - 4);
|
|
386
|
-
const total = formattedLines.length;
|
|
387
357
|
const maxOffset = Math.max(0, total - visibleCapacity);
|
|
388
358
|
this.lastWidth = width;
|
|
389
359
|
this.lastHeight = height;
|
|
@@ -394,10 +364,10 @@ export class LogsView implements TuiView {
|
|
|
394
364
|
const scrollFromTop = maxOffset - clampedOffset;
|
|
395
365
|
|
|
396
366
|
const startIndex = Math.max(0, total - visibleCapacity - clampedOffset);
|
|
397
|
-
const
|
|
367
|
+
const visibleEntries = total === 0 ? [] : rawEntries.slice(startIndex, startIndex + visibleCapacity);
|
|
398
368
|
|
|
399
369
|
this.lastStartIndex = startIndex;
|
|
400
|
-
this.lastVisibleCount =
|
|
370
|
+
this.lastVisibleCount = visibleEntries.length;
|
|
401
371
|
|
|
402
372
|
// Scrollbar calculation
|
|
403
373
|
const hasScrollbar = total > visibleCapacity;
|
|
@@ -416,33 +386,64 @@ export class LogsView implements TuiView {
|
|
|
416
386
|
const finalRows: string[] = ["", ""];
|
|
417
387
|
const boxInnerW = Math.max(1, width - 2);
|
|
418
388
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
389
|
+
if (total === 0) {
|
|
390
|
+
finalRows.push("");
|
|
391
|
+
finalRows.push(
|
|
392
|
+
theme.muted(` Nenhum log registrado para o filtro atual [${this.filter}].`),
|
|
393
|
+
);
|
|
394
|
+
finalRows.push(
|
|
395
|
+
theme.muted(" Eventos de inicialização, requisições e alertas do proxy aparecerão aqui."),
|
|
396
|
+
);
|
|
397
|
+
while (finalRows.length < visibleCapacity + 2) {
|
|
398
|
+
finalRows.push("");
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
for (let r = 0; r < visibleCapacity; r++) {
|
|
402
|
+
if (r < visibleEntries.length) {
|
|
403
|
+
const actualIdx = startIndex + r;
|
|
404
|
+
const entry = visibleEntries[r];
|
|
405
|
+
let formattedLine = "";
|
|
406
|
+
if (entry && entry.message && entry.message.trim()) {
|
|
407
|
+
let tag = theme.dim("[INFO]");
|
|
408
|
+
if (entry.level === "WARN") {
|
|
409
|
+
tag = theme.yellow("[WARN]");
|
|
410
|
+
} else if (entry.level === "ERROR") {
|
|
411
|
+
tag = theme.red("[ERR]");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const prefix = `${theme.dim(entry.time)} ${tag} `;
|
|
415
|
+
const prefixW = 16;
|
|
416
|
+
const maxMsgW = Math.max(20, innerW - prefixW - 4);
|
|
417
|
+
const truncatedMsg = truncate(entry.message, maxMsgW);
|
|
418
|
+
formattedLine = `${prefix}${truncatedMsg}`;
|
|
419
|
+
}
|
|
429
420
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
let scrollChar: string;
|
|
434
|
-
if (isThumb) {
|
|
435
|
-
scrollChar = isHighlighted ? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m` : theme.cyan("█");
|
|
421
|
+
let styledText = formattedLine;
|
|
422
|
+
if (this.selectedLogIndex === actualIdx) {
|
|
423
|
+
styledText = theme.bgSelected(`▸ ${stripAnsi(formattedLine)} `);
|
|
436
424
|
} else {
|
|
437
|
-
|
|
425
|
+
styledText = ` ${formattedLine}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (hasScrollbar) {
|
|
429
|
+
const isThumb = r >= thumbTop && r < thumbTop + thumbSize;
|
|
430
|
+
const isHighlighted = this.isScrollbarHovered || this.isDraggingScrollbar;
|
|
431
|
+
let scrollChar: string;
|
|
432
|
+
if (isThumb) {
|
|
433
|
+
scrollChar = isHighlighted
|
|
434
|
+
? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m`
|
|
435
|
+
: theme.cyan("█");
|
|
436
|
+
} else {
|
|
437
|
+
scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
|
|
438
|
+
}
|
|
439
|
+
const padded = pad(styledText, boxInnerW - 1);
|
|
440
|
+
finalRows.push(`${padded}${scrollChar}`);
|
|
441
|
+
} else {
|
|
442
|
+
finalRows.push(styledText);
|
|
438
443
|
}
|
|
439
|
-
const padded = pad(styledText, boxInnerW - 1);
|
|
440
|
-
finalRows.push(`${padded}${scrollChar}`);
|
|
441
444
|
} else {
|
|
442
|
-
finalRows.push(
|
|
445
|
+
finalRows.push("");
|
|
443
446
|
}
|
|
444
|
-
} else {
|
|
445
|
-
finalRows.push("");
|
|
446
447
|
}
|
|
447
448
|
}
|
|
448
449
|
|