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" },