fix(webui): polish links and thought timing

This commit is contained in:
Xubin Ren 2026-06-02 15:08:34 +08:00
parent d5692bf94c
commit e8d4aff5be
6 changed files with 59 additions and 6 deletions

View File

@ -272,7 +272,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
aria-label={`Open link: ${label}`} aria-label={`Open link: ${label}`}
className={cn( className={cn(
"not-prose inline-flex max-w-full items-center gap-2 align-baseline", "not-prose inline-flex max-w-full items-center gap-2 align-baseline",
"text-primary no-underline underline-offset-2 hover:underline", "text-blue-600 no-underline underline-offset-2 hover:underline dark:text-blue-300",
)} )}
> >
<span <span
@ -410,7 +410,7 @@ export default function MarkdownTextRenderer({
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80" className="text-blue-600 underline underline-offset-2 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
{...props} {...props}
> >
{markdownChildren} {markdownChildren}
@ -508,7 +508,7 @@ export default function MarkdownTextRenderer({
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5", "prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal", "prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
"prose-blockquote:not-italic prose-blockquote:text-foreground/80", "prose-blockquote:not-italic prose-blockquote:text-foreground/80",
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80", "prose-a:text-blue-600 prose-a:underline-offset-2 hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-hr:my-6", "prose-hr:my-6",
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0", "prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal", "prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",

View File

@ -573,7 +573,7 @@ export function ReasoningBubble({
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium", "prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground", "prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]", "prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90", "prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-code:text-[0.92em]", "prose-code:text-[0.92em]",
)} )}
> >

View File

@ -36,7 +36,7 @@ export function ReasoningRow({
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium", "prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground", "prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]", "prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90", "prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-code:text-[0.92em]", "prose-code:text-[0.92em]",
)} )}
> >

View File

@ -145,7 +145,17 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i]; const candidate = prev[i];
if (!candidate.reasoningStreaming) continue; if (!candidate.reasoningStreaming) continue;
const merged: UIMessage = { ...candidate, reasoningStreaming: false }; const latencyMs =
candidate.latencyMs === undefined
&& Number.isFinite(candidate.createdAt)
&& candidate.createdAt > 1_000_000_000_000
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
: candidate.latencyMs;
const merged: UIMessage = {
...candidate,
reasoningStreaming: false,
...(latencyMs !== undefined ? { latencyMs } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
} }
return prev; return prev;

View File

@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => { describe("MarkdownTextRenderer", () => {
it("renders clickable markdown links in blue", () => {
render(<MarkdownTextRenderer>[local server](http://127.0.0.1:7891/)</MarkdownTextRenderer>);
const link = screen.getByRole("link", { name: "local server" });
expect(link).toHaveAttribute("href", "http://127.0.0.1:7891/");
expect(link).toHaveClass("text-blue-600", "dark:text-blue-300");
});
it("does not wrap complete fenced code blocks in an extra pre", () => { it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render( const { container } = render(
<MarkdownTextRenderer highlightCode={false}> <MarkdownTextRenderer highlightCode={false}>

View File

@ -1039,6 +1039,41 @@ describe("useNanobotStream", () => {
expect(result.current.messages[1].reasoningStreaming).toBe(false); expect(result.current.messages[1].reasoningStreaming).toBe(false);
}); });
it("stamps completed live Thought blocks with their own latency", async () => {
const dateNow = vi.spyOn(Date, "now");
let now = Date.UTC(2026, 5, 1, 0, 0, 0);
dateNow.mockImplementation(() => now);
try {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5-lat", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
await act(async () => {});
act(() => {
fake.emit("chat-r5-lat", {
event: "reasoning_delta",
chat_id: "chat-r5-lat",
text: "Thinking through the tests.",
});
});
await act(async () => {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(result.current.messages[0].createdAt).toBe(now);
now += 2100;
act(() => {
fake.emit("chat-r5-lat", { event: "reasoning_end", chat_id: "chat-r5-lat" });
});
expect(result.current.messages[0].reasoningStreaming).toBe(false);
expect(result.current.messages[0].latencyMs).toBe(2100);
} finally {
dateNow.mockRestore();
}
});
it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => { it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {