refactor(webui): disable React StrictMode and enhance Markdown rendering

This commit is contained in:
Xubin Ren 2026-05-16 08:33:15 +00:00
parent 2144af7cd0
commit cf09a8d691
7 changed files with 123 additions and 34 deletions

View File

@ -1,3 +1,4 @@
import { Children, isValidElement } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
@ -46,11 +47,19 @@ export default function MarkdownTextRenderer({
components={{
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (!match) {
if (match) {
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
@ -59,11 +68,36 @@ export default function MarkdownTextRenderer({
</code>
);
}
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
return <>{markdownChildren}</>;
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (

View File

@ -101,6 +101,7 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
const latencyMs = message.latencyMs;

View File

@ -114,10 +114,8 @@ export function ThreadShell({
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
if (chatId) pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
onTurnEnd?.();
}, [chatId, onTurnEnd, refreshHistory]);
}, [onTurnEnd]);
const {
messages,
isStreaming,
@ -147,8 +145,8 @@ export function ThreadShell({
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about. Once a fresh
// canonical replay arrives after turn_end, prefer it so live Markdown/tool
// rendering converges to the same shape as a manual refresh.
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
// so rendering converges to the same shape as a manual refresh.
setMessages((prev) => {
if (hasNewCanonicalHistory && historical.length > 0) {
pendingCanonicalHydrateRef.current.delete(chatId);

View File

@ -14,10 +14,19 @@ import type {
} from "@/lib/types";
interface StreamBuffer {
/** ID of the assistant message currently receiving deltas. */
/** ID of the assistant message currently receiving deltas (cleared on ``stream_end``). */
messageId: string;
/** Sequence of deltas accumulated in order. */
parts: string[];
}
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
function findStreamingAssistantId(prev: UIMessage[]): string | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming) return m.id;
if (m.role === "user") break;
}
return null;
}
/**
@ -286,25 +295,22 @@ export function useNanobotStream(
if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
const chunk = typeof ev.text === "string" ? ev.text : "";
setIsStreaming(true);
setMessages((prev) => {
// Reuse an in-flight assistant placeholder (typically created by
// ``reasoning_delta``) so the answer renders below its own
// thinking trace instead of in a parallel row.
const adopted = !buffer.current ? findActiveAssistantPlaceholder(prev) : null;
const adopted = findActiveAssistantPlaceholder(prev);
const streamingAssistId = findStreamingAssistantId(prev);
let targetId: string;
let next: UIMessage[];
if (buffer.current) {
targetId = buffer.current.messageId;
next = prev;
} else if (adopted) {
if (adopted) {
targetId = adopted;
buffer.current = { messageId: targetId, parts: [] };
next = prev;
} else if (streamingAssistId) {
targetId = streamingAssistId;
next = prev;
} else {
targetId = crypto.randomUUID();
buffer.current = { messageId: targetId, parts: [] };
next = [
...prev,
{
@ -316,8 +322,11 @@ export function useNanobotStream(
},
];
}
buffer.current.parts.push(chunk);
const combined = buffer.current.parts.join("");
buffer.current = { messageId: targetId };
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
const combined = priorContent + chunk;
return next.map((m) =>
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
);

View File

@ -12,6 +12,44 @@ import type {
const WS_OPEN = 1;
const WS_CLOSING = 2;
/** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
*
* - **Dev** (non-production bundle): **on by default** messages appear at default log level.
* - **Production**: off unless ``localStorage.setItem('nanobot_debug_ws','1')`` (or ``true``).
* - **Silence anywhere**: ``localStorage.setItem('nanobot_debug_ws','0')`` (or ``false`` / ``off``).
* Values are read on every frame; no reload needed.
*/
function wsInboundDebugEnabled(): boolean {
if (typeof globalThis === "undefined") return false;
try {
if (import.meta.env.MODE === "test") return false;
const ls = (globalThis as unknown as { localStorage?: Storage }).localStorage;
const raw = ls?.getItem("nanobot_debug_ws")?.trim().toLowerCase() ?? "";
if (raw === "0" || raw === "false" || raw === "off" || raw === "no") {
return false;
}
if (raw === "1" || raw === "true" || raw === "on" || raw === "yes") {
return true;
}
return !import.meta.env.PROD;
} catch {
return !import.meta.env.PROD;
}
}
/** Shorten streaming text fields so logging stays usable for huge deltas. */
function summarizeInboundWsPayload(ev: InboundEvent): unknown {
const kind = (ev as { event?: string }).event;
if (kind !== "delta" && kind !== "reasoning_delta") return ev;
const row = { ...(ev as object) } as Record<string, unknown>;
const text = typeof row.text === "string" ? row.text : "";
const max = 240;
if (text.length > max) {
row.text = `${text.slice(0, max)}… (${text.length} chars)`;
}
return row;
}
type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
@ -289,9 +327,20 @@ export class NanobotClient {
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
} catch {
if (wsInboundDebugEnabled()) {
const raw = typeof ev.data === "string" ? ev.data : String(ev.data);
console.warn(
"[nanobot ws inbound] invalid JSON",
raw.length > 400 ? `${raw.slice(0, 400)}… (${raw.length} chars)` : raw,
);
}
return;
}
if (wsInboundDebugEnabled()) {
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
}
if (parsed.event === "ready") {
this.readyChatId = parsed.chat_id;
this.knownChats.add(parsed.chat_id);

View File

@ -24,8 +24,5 @@ if (typeof globalThis.crypto !== "undefined" && !("randomUUID" in globalThis.cry
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
ReactDOM.createRoot(root).render(<App />);

View File

@ -593,7 +593,7 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("replaces live streamed content with canonical history after turn end", async () => {
it("does not refetch thread history on turn_end", async () => {
const client = makeClient();
let historyCalls = 0;
vi.stubGlobal(
@ -646,8 +646,9 @@ describe("ThreadShell", () => {
});
});
await waitFor(() => expect(screen.getByText("canonical markdown answer")).toBeInTheDocument());
expect(screen.queryByText("live half-parsed | markdown")).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText("live half-parsed | markdown")).toBeInTheDocument());
expect(screen.queryByText("canonical markdown answer")).not.toBeInTheDocument();
expect(historyCalls).toBe(1);
});
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {