mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 08:02:30 +00:00
refactor(webui): disable React StrictMode and enhance Markdown rendering
This commit is contained in:
parent
2144af7cd0
commit
cf09a8d691
@ -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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 />);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user