mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
fix(frontend): render citation links from React children (#4486)
* fix(frontend): render citation links from React children * fix(frontend): handle nested citation link text
This commit is contained in:
parent
2654bc60da
commit
7a981c309c
@ -98,6 +98,7 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
|
||||
- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
|
||||
- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and rejoins after the server-provided retained tail, with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
|
||||
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
|
||||
- Citation links in message and artifact Markdown must derive their `citation:` label from the full `ReactNode` children tree, since Streamdown may provide element or array children during streaming rather than a plain string.
|
||||
- **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1`
|
||||
- **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint.
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { isSafeHref } from "../messages/markdown-link";
|
||||
|
||||
import { CitationLink } from "./citation-link";
|
||||
import { CitationLink, extractReactNodeText } from "./citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
return !!href && /^https?:\/\//.test(href);
|
||||
@ -33,8 +33,9 @@ export function ArtifactLink(props: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
const childrenText = extractReactNodeText(props.children);
|
||||
if (childrenText !== null) {
|
||||
const match = /^citation:(.+)$/.exec(childrenText);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { isValidElement, type ComponentProps, type ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@ -9,6 +9,25 @@ import {
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Extract visible text from renderer-provided ReactNode children. */
|
||||
export function extractReactNodeText(node: ReactNode): string | null {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
const text = node
|
||||
.map(extractReactNodeText)
|
||||
.filter((value): value is string => value !== null)
|
||||
.join("");
|
||||
return text || null;
|
||||
}
|
||||
if (isValidElement(node)) {
|
||||
const children = (node.props as { children?: ReactNode }).children;
|
||||
return children === undefined ? null : extractReactNodeText(children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
@ -18,9 +37,7 @@ export function CitationLink({
|
||||
|
||||
// Priority: children > domain
|
||||
const childrenText =
|
||||
typeof children === "string"
|
||||
? children.replace(/^citation:\s*/i, "")
|
||||
: null;
|
||||
extractReactNodeText(children)?.replace(/^citation:\s*/i, "") ?? null;
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type { AnchorHTMLAttributes } from "react";
|
||||
import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CitationLink, extractReactNodeText } from "../citations/citation-link";
|
||||
|
||||
/**
|
||||
* Schemes we are willing to render as a navigable ``<a href=...>``.
|
||||
@ -91,8 +91,9 @@ export function createMarkdownLinkComponent(threadId?: string) {
|
||||
);
|
||||
}
|
||||
// Safe-href check passed — citation links now route through CitationLink.
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
const childrenText = extractReactNodeText(props.children);
|
||||
if (childrenText !== null) {
|
||||
const match = /^citation:(.+)$/.exec(childrenText);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return (
|
||||
|
||||
@ -28,6 +28,18 @@ describe("ArtifactLink rendering", () => {
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it("renders citation labels when children are React elements", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
ArtifactLink,
|
||||
{ href: "https://example.com/report" },
|
||||
createElement("span", null, "citation:Test Source"),
|
||||
),
|
||||
);
|
||||
expect(html).not.toContain("citation:");
|
||||
expect(html).toContain("Test Source");
|
||||
});
|
||||
|
||||
it("renders scheme-less relative hrefs as navigable anchors", () => {
|
||||
const tests = ["report.md", "./report.md", "../assets/chart.png"];
|
||||
for (const href of tests) {
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CitationLink } from "@/components/workspace/citations/citation-link";
|
||||
|
||||
describe("CitationLink rendering", () => {
|
||||
it("strips the citation prefix from React element arrays", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
CitationLink,
|
||||
{ href: "https://example.com/report" },
|
||||
createElement("span", { key: "prefix" }, "citation:"),
|
||||
createElement("span", { key: "title" }, "Test Source"),
|
||||
),
|
||||
);
|
||||
|
||||
expect(html).not.toContain("citation:");
|
||||
expect(html).toContain("Test Source");
|
||||
});
|
||||
});
|
||||
@ -78,6 +78,19 @@ describe("MarkdownLink rendering", () => {
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it("renders citation labels when children are React element arrays", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
MarkdownLink,
|
||||
{ href: "https://example.com/report" },
|
||||
createElement("span", { key: "prefix" }, "citation:"),
|
||||
createElement("span", { key: "title" }, "Test Source"),
|
||||
),
|
||||
);
|
||||
expect(html).not.toContain("citation:");
|
||||
expect(html).toContain("Test Source");
|
||||
});
|
||||
|
||||
it("renders a scheme-less relative href as a navigable anchor", () => {
|
||||
const tests = ["report.md", "./report.md", "../assets/chart.png"];
|
||||
for (const href of tests) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user