feat: add frontend remote log uploader for Docker visibility
- POST /api/client-log endpoint in http.ts (Hono, no auth, 204 response) prints batched log entries to stdout so `docker compose logs` shows them - New src/utils/remote-log.ts: remoteLog/remoteWarn batch to backend every 500ms - Replace all bracket-tagged diagnostic console.log/warn calls in main.tsx and ToolCallStatus.tsx with remoteLog/remoteWarn (tags: thread-ui, ui-debug, render-msg, orphan-match, orphan-name-match, orphan-result, streaming-orphan, tool-ui-match) - Add /api/client-log proxy entry to vite.config.ts so dev server forwards the request to the LangGraph backend (port 2024) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2218bbcfef
commit
157a3c6a83
@@ -68,6 +68,29 @@ function isSensitiveHeader(key: string): boolean {
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
// ── Client log endpoint ──────────────────────────────────────────────────────
|
||||
// Receives batched frontend diagnostic logs and prints them to stdout so they
|
||||
// are visible via `docker compose logs`. No authentication required.
|
||||
app.post("/api/client-log", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const entries = Array.isArray(body) ? body : [body];
|
||||
for (const entry of entries) {
|
||||
const { level = "log", tag = "client", data, timestamp } = entry as {
|
||||
level?: string;
|
||||
tag?: string;
|
||||
data?: unknown;
|
||||
timestamp?: string;
|
||||
};
|
||||
const ts = timestamp ?? new Date().toISOString();
|
||||
console.log(`[client-log][${tag}][${level}][${ts}] ${JSON.stringify(data)}`);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
// Collect header names to delete (can't mutate while iterating)
|
||||
const toDelete: string[] = [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown, AlertCircle, CornerDownRight } from "lucide-react";
|
||||
import { useState, useCallback, Component, type ReactNode } from "react";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { remoteLog } from "@/utils/remote-log";
|
||||
|
||||
// ErrorBoundary to prevent Gen-UI card crashes from taking down the whole app
|
||||
class CardErrorBoundary extends Component<
|
||||
@@ -353,7 +354,7 @@ export default function ToolCallStatus({
|
||||
matchCounters[uiName] = (matchCounters[uiName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
console.log('[tool-ui-match]', {
|
||||
remoteLog('tool-ui-match', {
|
||||
toolName: tc.name,
|
||||
uiName: tc.name ? UI_NAME_MAP[tc.name] : undefined,
|
||||
uiItemsCount: uiItems.length,
|
||||
|
||||
+41
-26
@@ -20,6 +20,7 @@ import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButt
|
||||
import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx";
|
||||
import ToolCallStatus from "@/components/ToolCallStatus.tsx";
|
||||
import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx";
|
||||
import { remoteLog, remoteWarn } from "@/utils/remote-log";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
||||
@@ -235,7 +236,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ui = (thread.values as any)?.ui ?? [];
|
||||
console.log('[thread-ui]', {
|
||||
remoteLog('thread-ui', {
|
||||
count: ui.length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
items: ui.map((u: any) => ({ name: u.name, msgId: u.metadata?.message_id?.slice(0,8), cardId: u.props?.card_id })),
|
||||
@@ -772,15 +773,15 @@ function App() {
|
||||
const cardId = ui.props?.card_id as string | undefined;
|
||||
if (cardId) {
|
||||
const matched = msgToolCalls.some((tc) => tc.id && cardId.includes(tc.id));
|
||||
console.log('[orphan-match]', { cardId, tcIds: msgToolCalls.map(tc => tc.id), matched });
|
||||
remoteWarn('orphan-match', { cardId, tcIds: msgToolCalls.map(tc => tc.id), matched });
|
||||
return matched;
|
||||
}
|
||||
// Fallback: match by UI component name
|
||||
const nameMatched = expectedUiNames.includes(ui.name);
|
||||
console.log('[orphan-name-match]', { uiName: ui.name, expectedUiNames, nameMatched });
|
||||
remoteWarn('orphan-name-match', { uiName: ui.name, expectedUiNames, nameMatched });
|
||||
return nameMatched;
|
||||
});
|
||||
console.log('[orphan-result]', { orphanCount: orphans.length, matchedCount: matchedUi.length, msgId: message.id?.slice(0,12) });
|
||||
remoteWarn('orphan-result', { orphanCount: orphans.length, matchedCount: matchedUi.length, msgId: message.id?.slice(0,12) });
|
||||
} else if (idx === activeMessages.length - 1) {
|
||||
// Last AI message without tool_calls: adopt all remaining orphans
|
||||
// (these may include next-actions, chart-result, etc.)
|
||||
@@ -803,7 +804,7 @@ function App() {
|
||||
matchedUi = orphans.filter((ui) => !claimedByToolMsgs.has(ui.id));
|
||||
}
|
||||
}
|
||||
console.log('[ui-debug]', {
|
||||
remoteLog('ui-debug', {
|
||||
messageId: message.id,
|
||||
messageType: message.type,
|
||||
allUiCount: allUi.length,
|
||||
@@ -837,6 +838,16 @@ function App() {
|
||||
return sa - sb;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasToolCalls_diag = ((message as any).tool_calls ?? []).length > 0;
|
||||
const hasThinking_diag = Array.isArray(message.content) && (message.content as any[]).some((c) => c.type === "thinking");
|
||||
const plainTextLen_diag = typeof message.content === "string"
|
||||
? message.content.length
|
||||
: Array.isArray(message.content)
|
||||
? (message.content as any[]).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").length
|
||||
: 0;
|
||||
remoteWarn('render-msg', { idx, msgId: message.id?.slice(0,12), type: message.type, hasToolCalls: hasToolCalls_diag, hasThinking: hasThinking_diag, plainTextLen: plainTextLen_diag, matchedUiCount: uiItems.length, uiNames: uiItems.map(u => u.name) });
|
||||
|
||||
if (message.type === "human") {
|
||||
const humanText = typeof message.content === "string"
|
||||
? message.content
|
||||
@@ -1039,26 +1050,29 @@ function App() {
|
||||
|
||||
{/* Streaming UI cards not yet attached to a completed message */}
|
||||
{thread.isLoading &&
|
||||
deduplicateUiItems(
|
||||
(thread.values?.ui ?? []).filter((ui: UIMsgLocal) => {
|
||||
// Check if attached by message_id
|
||||
const attachedByMsgId = activeMessages.some(
|
||||
(m) => m.id === ui.metadata?.message_id,
|
||||
);
|
||||
if (attachedByMsgId) return false;
|
||||
// Check if attached by card_id matching any tool_call_id (orphan logic)
|
||||
const cardId = ui.props?.card_id as string | undefined;
|
||||
if (cardId) {
|
||||
const claimedByToolCall = activeMessages.some((m) => {
|
||||
const tcs: { id?: string }[] = (m as any).tool_calls ?? [];
|
||||
return tcs.some((tc) => tc.id && cardId.includes(tc.id));
|
||||
});
|
||||
if (claimedByToolCall) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.map((ui: UIMsgLocal) => (
|
||||
(() => {
|
||||
const allStreamUi: UIMsgLocal[] = thread.values?.ui ?? [];
|
||||
const orphans = deduplicateUiItems(
|
||||
allStreamUi.filter((ui: UIMsgLocal) => {
|
||||
// Check if attached by message_id
|
||||
const attachedByMsgId = activeMessages.some(
|
||||
(m) => m.id === ui.metadata?.message_id,
|
||||
);
|
||||
if (attachedByMsgId) return false;
|
||||
// Check if attached by card_id matching any tool_call_id (orphan logic)
|
||||
const cardId = ui.props?.card_id as string | undefined;
|
||||
if (cardId) {
|
||||
const claimedByToolCall = activeMessages.some((m) => {
|
||||
const tcs: { id?: string }[] = (m as any).tool_calls ?? [];
|
||||
return tcs.some((tc) => tc.id && cardId.includes(tc.id));
|
||||
});
|
||||
if (claimedByToolCall) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
remoteWarn('streaming-orphan', { totalUi: allStreamUi.length, orphanCount: orphans.length, orphanNames: orphans.map(u => u.name + ':' + String(u.props?.card_id ?? '').slice(0,20)) });
|
||||
return orphans.map((ui: UIMsgLocal) => (
|
||||
<div key={ui.id} className="card-enter">
|
||||
<CardErrorBoundary>
|
||||
<LoadExternalComponent
|
||||
@@ -1070,7 +1084,8 @@ function App() {
|
||||
/>
|
||||
</CardErrorBoundary>
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* ── Thread error banner ── */}
|
||||
{!!thread.error && !thread.isLoading && (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Remote logger: sends frontend console logs to backend for Docker visibility.
|
||||
* Batches logs and sends them every 500ms to avoid flooding.
|
||||
*/
|
||||
|
||||
const LOG_ENDPOINT = "/api/client-log";
|
||||
const BATCH_INTERVAL = 500;
|
||||
|
||||
interface LogEntry {
|
||||
level: string;
|
||||
tag: string;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let buffer: LogEntry[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function flush() {
|
||||
if (buffer.length === 0) return;
|
||||
const batch = buffer;
|
||||
buffer = [];
|
||||
// Fire and forget
|
||||
fetch(LOG_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(batch),
|
||||
}).catch(() => {/* silent */});
|
||||
}
|
||||
|
||||
function enqueue(level: string, tag: string, data: unknown) {
|
||||
buffer.push({ level, tag, data, timestamp: new Date().toISOString() });
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
flush();
|
||||
}, BATCH_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteLog(tag: string, ...args: unknown[]) {
|
||||
const data = args.length === 1 ? args[0] : args;
|
||||
console.log(`[${tag}]`, data); // 保留本地 console 输出
|
||||
enqueue("log", tag, data);
|
||||
}
|
||||
|
||||
export function remoteWarn(tag: string, ...args: unknown[]) {
|
||||
const data = args.length === 1 ? args[0] : args;
|
||||
console.warn(`[${tag}]`, data);
|
||||
enqueue("warn", tag, data);
|
||||
}
|
||||
@@ -75,6 +75,10 @@ export default defineConfig({
|
||||
target: process.env.LANGGRAPH_BACKEND_URL ?? process.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api/client-log": {
|
||||
target: process.env.LANGGRAPH_BACKEND_URL ?? process.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user