mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12: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 ReactMarkdown from "react-markdown";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@ -46,11 +47,19 @@ export default function MarkdownTextRenderer({
|
|||||||
components={{
|
components={{
|
||||||
code({ className: cls, children: kids, ...props }) {
|
code({ className: cls, children: kids, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(cls || "");
|
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 (
|
return (
|
||||||
<code
|
<code
|
||||||
className={cn(
|
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,
|
cls,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -59,11 +68,36 @@ export default function MarkdownTextRenderer({
|
|||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const code = String(kids).replace(/\n$/, "");
|
return (
|
||||||
return <CodeBlock language={match[1]} code={code} className="my-3" />;
|
<code
|
||||||
|
className={cn(
|
||||||
|
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||||
|
cls,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{kids}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
pre({ children: markdownChildren }) {
|
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 }) {
|
a({ href, children: markdownChildren, ...props }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -101,6 +101,7 @@ export function MessageBubble({
|
|||||||
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
|
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
|
||||||
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
||||||
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
||||||
|
|
||||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||||
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
||||||
const latencyMs = message.latencyMs;
|
const latencyMs = message.latencyMs;
|
||||||
|
|||||||
@ -114,10 +114,8 @@ export function ThreadShell({
|
|||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical]);
|
}, [chatId, historical]);
|
||||||
const handleTurnEnd = useCallback(() => {
|
const handleTurnEnd = useCallback(() => {
|
||||||
if (chatId) pendingCanonicalHydrateRef.current.add(chatId);
|
|
||||||
refreshHistory();
|
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
}, [chatId, onTurnEnd, refreshHistory]);
|
}, [onTurnEnd]);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@ -147,8 +145,8 @@ export function ThreadShell({
|
|||||||
// When the user switches away and back, keep the local in-memory thread
|
// When the user switches away and back, keep the local in-memory thread
|
||||||
// state (including not-yet-persisted messages) instead of replacing it with
|
// state (including not-yet-persisted messages) instead of replacing it with
|
||||||
// whatever the history endpoint currently knows about. Once a fresh
|
// whatever the history endpoint currently knows about. Once a fresh
|
||||||
// canonical replay arrives after turn_end, prefer it so live Markdown/tool
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||||
// rendering converges to the same shape as a manual refresh.
|
// so rendering converges to the same shape as a manual refresh.
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||||
|
|||||||
@ -14,10 +14,19 @@ import type {
|
|||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
interface StreamBuffer {
|
interface StreamBuffer {
|
||||||
/** ID of the assistant message currently receiving deltas. */
|
/** ID of the assistant message currently receiving deltas (cleared on ``stream_end``). */
|
||||||
messageId: string;
|
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 (ev.event === "delta") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = ev.text;
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
// Reuse an in-flight assistant placeholder (typically created by
|
const adopted = findActiveAssistantPlaceholder(prev);
|
||||||
// ``reasoning_delta``) so the answer renders below its own
|
const streamingAssistId = findStreamingAssistantId(prev);
|
||||||
// thinking trace instead of in a parallel row.
|
|
||||||
const adopted = !buffer.current ? findActiveAssistantPlaceholder(prev) : null;
|
|
||||||
let targetId: string;
|
let targetId: string;
|
||||||
let next: UIMessage[];
|
let next: UIMessage[];
|
||||||
if (buffer.current) {
|
|
||||||
targetId = buffer.current.messageId;
|
if (adopted) {
|
||||||
next = prev;
|
|
||||||
} else if (adopted) {
|
|
||||||
targetId = adopted;
|
targetId = adopted;
|
||||||
buffer.current = { messageId: targetId, parts: [] };
|
next = prev;
|
||||||
|
} else if (streamingAssistId) {
|
||||||
|
targetId = streamingAssistId;
|
||||||
next = prev;
|
next = prev;
|
||||||
} else {
|
} else {
|
||||||
targetId = crypto.randomUUID();
|
targetId = crypto.randomUUID();
|
||||||
buffer.current = { messageId: targetId, parts: [] };
|
|
||||||
next = [
|
next = [
|
||||||
...prev,
|
...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) =>
|
return next.map((m) =>
|
||||||
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,44 @@ import type {
|
|||||||
const WS_OPEN = 1;
|
const WS_OPEN = 1;
|
||||||
const WS_CLOSING = 2;
|
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 Unsubscribe = () => void;
|
||||||
type EventHandler = (ev: InboundEvent) => void;
|
type EventHandler = (ev: InboundEvent) => void;
|
||||||
type StatusHandler = (status: ConnectionStatus) => void;
|
type StatusHandler = (status: ConnectionStatus) => void;
|
||||||
@ -289,9 +327,20 @@ export class NanobotClient {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
|
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
|
||||||
} catch {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wsInboundDebugEnabled()) {
|
||||||
|
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.event === "ready") {
|
if (parsed.event === "ready") {
|
||||||
this.readyChatId = parsed.chat_id;
|
this.readyChatId = parsed.chat_id;
|
||||||
this.knownChats.add(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");
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error("root element missing");
|
if (!root) throw new Error("root element missing");
|
||||||
|
|
||||||
ReactDOM.createRoot(root).render(
|
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
||||||
<React.StrictMode>
|
ReactDOM.createRoot(root).render(<App />);
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
|
|||||||
@ -593,7 +593,7 @@ describe("ThreadShell", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
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();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@ -646,8 +646,9 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("canonical markdown answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("live half-parsed | markdown")).toBeInTheDocument());
|
||||||
expect(screen.queryByText("live half-parsed | markdown")).not.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 () => {
|
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