diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js
index 12db16f..d9dde1a 100644
--- a/open-sse/handlers/chatCore/requestDetail.js
+++ b/open-sse/handlers/chatCore/requestDetail.js
@@ -80,7 +80,7 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
if (inTokens === 0 && outTokens === 0) return;
- const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
+ const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js
index 20a2ec4..50cfe06 100644
--- a/open-sse/utils/streamHelpers.js
+++ b/open-sse/utils/streamHelpers.js
@@ -57,6 +57,33 @@ export function fixInvalidId(parsed) {
return false;
}
+function cleanUsagePayload(payload) {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return payload;
+ }
+
+ let cleaned = payload;
+
+ if ("usage" in cleaned) {
+ if (cleaned.usage === null) {
+ const { usage, ...payloadWithoutUsage } = cleaned;
+ cleaned = payloadWithoutUsage;
+ } else if (typeof cleaned.usage === "object" && cleaned.usage.perf_metrics === null) {
+ const { perf_metrics, ...usageWithoutPerf } = cleaned.usage;
+ cleaned = { ...cleaned, usage: usageWithoutPerf };
+ }
+ }
+
+ if (cleaned.response && typeof cleaned.response === "object" && !Array.isArray(cleaned.response)) {
+ const cleanedResponse = cleanUsagePayload(cleaned.response);
+ if (cleanedResponse !== cleaned.response) {
+ cleaned = { ...cleaned, response: cleanedResponse };
+ }
+ }
+
+ return cleaned;
+}
+
// Format output as SSE
export function formatSSE(data, sourceFormat) {
if (data === null || data === undefined) return "data: null\n\n";
@@ -64,23 +91,16 @@ export function formatSSE(data, sourceFormat) {
// OpenAI Responses API format
if (data && data.event && data.data) {
- return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
+ const cleanedEventData = cleanUsagePayload(data.data);
+ return `event: ${data.event}\ndata: ${JSON.stringify(cleanedEventData)}\n\n`;
}
+ data = cleanUsagePayload(data);
+
// Claude format
if (sourceFormat === FORMATS.CLAUDE && data && data.type) {
- if (data.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
- const { perf_metrics, ...usageWithoutPerf } = data.usage;
- data = { ...data, usage: usageWithoutPerf };
- }
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
}
- // Remove null perf_metrics
- if (data?.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
- const { perf_metrics, ...usageWithoutPerf } = data.usage;
- data = { ...data, usage: usageWithoutPerf };
- }
-
return `data: ${JSON.stringify(data)}\n\n`;
}
diff --git a/package.json b/package.json
index fd8568b..54f8835 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.3.23",
+ "version": "0.3.24",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js b/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js
new file mode 100644
index 0000000..6373015
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js
@@ -0,0 +1,91 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Card, Button } from "@/shared/components";
+import { CONSOLE_LOG_CONFIG } from "@/shared/constants/config";
+
+const LOG_LEVEL_COLORS = {
+ LOG: "text-green-400",
+ INFO: "text-blue-400",
+ WARN: "text-yellow-400",
+ ERROR: "text-red-400",
+ DEBUG: "text-purple-400",
+};
+
+function colorLine(line) {
+ const match = line.match(/\[(\w+)\]/g);
+ const levelTag = match ? match[1]?.replace(/\[|\]/g, "") : null;
+ const color = LOG_LEVEL_COLORS[levelTag] || "text-green-400";
+ return {line};
+}
+
+export default function ConsoleLogClient() {
+ const [logs, setLogs] = useState([]);
+ const [connected, setConnected] = useState(false);
+ const logRef = useRef(null);
+
+ const handleClear = async () => {
+ try {
+ await fetch("/api/translator/console-logs", { method: "DELETE" });
+ // UI cleared via SSE "clear" event
+ } catch (err) {
+ console.error("Failed to clear console logs:", err);
+ }
+ };
+
+ useEffect(() => {
+ const es = new EventSource("/api/translator/console-logs/stream");
+
+ es.onopen = () => setConnected(true);
+
+ es.onmessage = (e) => {
+ const msg = JSON.parse(e.data);
+ if (msg.type === "init") {
+ setLogs(msg.logs.slice(-CONSOLE_LOG_CONFIG.maxLines));
+ } else if (msg.type === "line") {
+ setLogs((prev) => {
+ const next = [...prev, msg.line];
+ return next.length > CONSOLE_LOG_CONFIG.maxLines ? next.slice(-CONSOLE_LOG_CONFIG.maxLines) : next;
+ });
+ } else if (msg.type === "clear") {
+ setLogs([]);
+ }
+ };
+
+ es.onerror = () => setConnected(false);
+
+ return () => es.close();
+ }, []);
+
+ // Auto-scroll to bottom on new logs
+ useEffect(() => {
+ if (!logRef.current) return;
+ logRef.current.scrollTop = logRef.current.scrollHeight;
+ }, [logs]);
+
+ return (
+
+
+
+
+
+
+ {logs.length === 0 ? (
+
No console logs yet.
+ ) : (
+
+ {logs.map((line, i) => (
+
{colorLine(line)}
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/console-log/page.js b/src/app/(dashboard)/dashboard/console-log/page.js
new file mode 100644
index 0000000..863cef8
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/console-log/page.js
@@ -0,0 +1,8 @@
+import ConsoleLogClient from "./ConsoleLogClient";
+
+// Force dynamic so Next.js standalone build includes the server-side JS file
+export const dynamic = "force-dynamic";
+
+export default function ConsoleLogPage() {
+ return ;
+}
diff --git a/src/app/api/translator/console-logs/route.js b/src/app/api/translator/console-logs/route.js
new file mode 100644
index 0000000..43d3e59
--- /dev/null
+++ b/src/app/api/translator/console-logs/route.js
@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { clearConsoleLogs, getConsoleLogs, initConsoleLogCapture } from "@/lib/consoleLogBuffer";
+
+initConsoleLogCapture();
+
+export async function GET() {
+ try {
+ const logs = getConsoleLogs();
+ return NextResponse.json({ success: true, logs });
+ } catch (error) {
+ console.error("Error getting console logs:", error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+export async function DELETE() {
+ try {
+ clearConsoleLogs();
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Error clearing console logs:", error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/translator/console-logs/stream/route.js b/src/app/api/translator/console-logs/stream/route.js
new file mode 100644
index 0000000..30ea07d
--- /dev/null
+++ b/src/app/api/translator/console-logs/stream/route.js
@@ -0,0 +1,70 @@
+import { getConsoleLogs, getConsoleEmitter, initConsoleLogCapture } from "@/lib/consoleLogBuffer";
+
+export const dynamic = "force-dynamic";
+
+initConsoleLogCapture();
+
+export async function GET() {
+ const encoder = new TextEncoder();
+ const emitter = getConsoleEmitter();
+ const state = { closed: false, send: null, keepalive: null };
+
+ const stream = new ReadableStream({
+ start(controller) {
+ // Send all buffered logs immediately on connect
+ const buffered = getConsoleLogs();
+ if (buffered.length > 0) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "init", logs: buffered })}\n\n`));
+ }
+
+ // Push new lines as they arrive
+ state.send = (line) => {
+ if (state.closed) return;
+ try {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "line", line })}\n\n`));
+ } catch {
+ state.closed = true;
+ }
+ };
+
+ // Notify client when cleared
+ state.sendClear = () => {
+ if (state.closed) return;
+ try {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "clear" })}\n\n`));
+ } catch {
+ state.closed = true;
+ }
+ };
+
+ emitter.on("line", state.send);
+ emitter.on("clear", state.sendClear);
+
+ // Keepalive ping every 25s
+ state.keepalive = setInterval(() => {
+ if (state.closed) { clearInterval(state.keepalive); return; }
+ try {
+ controller.enqueue(encoder.encode(": ping\n\n"));
+ } catch {
+ state.closed = true;
+ clearInterval(state.keepalive);
+ }
+ }, 25000);
+ },
+
+ cancel() {
+ state.closed = true;
+ emitter.off("line", state.send);
+ emitter.off("clear", state.sendClear);
+ clearInterval(state.keepalive);
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ });
+}
diff --git a/src/app/api/version/route.js b/src/app/api/version/route.js
index b30ce9d..7e7f4a8 100644
--- a/src/app/api/version/route.js
+++ b/src/app/api/version/route.js
@@ -38,7 +38,6 @@ function compareVersions(a, b) {
export async function GET() {
const latestVersion = await fetchLatestVersion();
- console.log("🚀 ~ GET ~ latestVersion:", latestVersion)
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;
diff --git a/src/app/layout.js b/src/app/layout.js
index 6f5f316..391fbb2 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -3,6 +3,10 @@ import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
+import { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
+
+// Hook console immediately at module load time (server-side only, runs once)
+initConsoleLogCapture();
const inter = Inter({
subsets: ["latin"],
diff --git a/src/lib/consoleLogBuffer.js b/src/lib/consoleLogBuffer.js
new file mode 100644
index 0000000..afff411
--- /dev/null
+++ b/src/lib/consoleLogBuffer.js
@@ -0,0 +1,79 @@
+import { EventEmitter } from "events";
+import { CONSOLE_LOG_CONFIG } from "@/shared/constants/config.js";
+
+const consoleLevels = ["log", "info", "warn", "error", "debug"];
+
+if (!global._consoleLogBufferState) {
+ global._consoleLogBufferState = {
+ logs: [],
+ patched: false,
+ originals: {},
+ emitter: new EventEmitter(),
+ };
+ global._consoleLogBufferState.emitter.setMaxListeners(50);
+}
+
+const state = global._consoleLogBufferState;
+
+// Ensure emitter exists (handles hot reload with stale global)
+if (!state.emitter) {
+ state.emitter = new EventEmitter();
+ state.emitter.setMaxListeners(50);
+}
+
+function toLogLine(level, args) {
+ return args.map(formatArg).join(" ");
+}
+
+// Strip ANSI escape codes so terminal colors don't bleed into UI
+const ANSI_RE = /\x1b\[[0-9;]*m/g;
+
+function stripAnsi(str) {
+ return str.replace(ANSI_RE, "");
+}
+
+function formatArg(arg) {
+ if (typeof arg === "string") return stripAnsi(arg);
+ if (arg instanceof Error) return stripAnsi(arg.stack || arg.message || String(arg));
+ try {
+ return stripAnsi(JSON.stringify(arg));
+ } catch {
+ return stripAnsi(String(arg));
+ }
+}
+
+function appendLine(line) {
+ state.logs.push(line);
+ const maxLines = CONSOLE_LOG_CONFIG.maxLines;
+ if (state.logs.length > maxLines) {
+ state.logs = state.logs.slice(-maxLines);
+ }
+ state.emitter.emit("line", line);
+}
+
+export function initConsoleLogCapture() {
+ if (state.patched) return;
+
+ for (const level of consoleLevels) {
+ state.originals[level] = console[level];
+ console[level] = (...args) => {
+ appendLine(toLogLine(level, args));
+ state.originals[level](...args);
+ };
+ }
+
+ state.patched = true;
+}
+
+export function getConsoleLogs() {
+ return state.logs;
+}
+
+export function clearConsoleLogs() {
+ state.logs = [];
+ state.emitter.emit("clear");
+}
+
+export function getConsoleEmitter() {
+ return state.emitter;
+}
diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js
index 1f55299..838122a 100644
--- a/src/lib/usageDb.js
+++ b/src/lib/usageDb.js
@@ -120,7 +120,8 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
lastErrorProvider.ts = Date.now();
}
- console.log(`[PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("pending")}`);
+ const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
statsEmitter.emit("pending");
}
diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js
index 4c8346e..cf8bfe6 100644
--- a/src/shared/components/Header.js
+++ b/src/shared/components/Header.js
@@ -33,6 +33,8 @@ const getPageInfo = (pathname) => {
if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
+ if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", breadcrumbs: [] };
+ if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", breadcrumbs: [] };
if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
return { title: "", description: "", breadcrumbs: [] };
};
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js
index 2804c55..7e53315 100644
--- a/src/shared/components/Sidebar.js
+++ b/src/shared/components/Sidebar.js
@@ -19,7 +19,8 @@ const navItems = [
// Debug items (only show when ENABLE_REQUEST_LOGS=true)
const debugItems = [
- { href: "/dashboard/translator", label: "Translator", icon: "translate" },
+ // { href: "/dashboard/translator", label: "Translator", icon: "translate" },
+ { href: "/dashboard/console-log", label: "Console Log", icon: "terminal" },
];
const systemItems = [
@@ -31,17 +32,8 @@ export default function Sidebar({ onClose }) {
const [showShutdownModal, setShowShutdownModal] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const [isDisconnected, setIsDisconnected] = useState(false);
- const [showDebug, setShowDebug] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
- // Check if debug mode is enabled
- useEffect(() => {
- fetch("/api/settings")
- .then(res => res.json())
- .then(data => setShowDebug(data?.enableRequestLogs === true))
- .catch(() => {});
- }, []);
-
// Lazy check for new npm version on mount
useEffect(() => {
fetch("/api/version")
@@ -130,9 +122,8 @@ export default function Sidebar({ onClose }) {
))}
- {/* Debug section (only show when ENABLE_REQUEST_LOGS=true) */}
- {showDebug && (
-
+ {/* Debug section */}
+
Debug
@@ -160,7 +151,6 @@ export default function Sidebar({ onClose }) {
))}
- )}
{/* System section */}
diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js
index adad400..6c2afb5 100644
--- a/src/shared/constants/config.js
+++ b/src/shared/constants/config.js
@@ -29,6 +29,11 @@ export const API_ENDPOINTS = {
auth: "/api/auth",
};
+export const CONSOLE_LOG_CONFIG = {
+ maxLines: 200,
+ pollIntervalMs: 1000,
+};
+
// Provider API endpoints (for display only)
export const PROVIDER_ENDPOINTS = {
openrouter: "https://openrouter.ai/api/v1/chat/completions",
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index 87d0594..507df14 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -26,7 +26,7 @@ export const APIKEY_PROVIDERS = {
kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn" },
minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com" },
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" },
- alicode: { id: "alicode", alias: "alicode", name: "Aliyun Bailian", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
+ alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com" },
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com" },
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" },