();
for (const c of all) {
const key = c.documentId ?? c.filename;
const prev = byDoc.get(key);
- if (!prev || (c.score ?? -Infinity) > (prev.score ?? -Infinity)) {
+ if (
+ !prev ||
+ (c.score ?? Number.NEGATIVE_INFINITY) >
+ (prev.score ?? Number.NEGATIVE_INFINITY)
+ ) {
byDoc.set(key, c);
}
}
const sources = Array.from(byDoc.values());
- if (sources.length === 0) return null;
+ if (sources.length === 0) {
+ return null;
+ }
return (
@@ -44,3 +43,18 @@ export const RagSourcesGroup: FC = () => {
);
};
+
+export const RagSourcesGroup: FC = () => {
+ const message = useMessage();
+
+ const sources: Citation[] = [];
+ for (const part of message.content ?? []) {
+ if (
+ part.type === "tool-call" &&
+ part.toolName === "search_knowledge_base"
+ ) {
+ sources.push(...parseCitations(part.result));
+ }
+ }
+ return ;
+};
diff --git a/studio/frontend/src/components/markdown/markdown-preview.tsx b/studio/frontend/src/components/markdown/markdown-preview.tsx
index 34e516e74d..6421bc0129 100644
--- a/studio/frontend/src/components/markdown/markdown-preview.tsx
+++ b/studio/frontend/src/components/markdown/markdown-preview.tsx
@@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { openLink } from "@/lib/open-link";
+import { safeMarkdownUrl } from "@/lib/safe-markdown-url";
import { cn } from "@/lib/utils";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
@@ -56,6 +57,7 @@ function MarkdownPreviewImpl({
mode="static"
plugins={MARKDOWN_PLUGINS}
components={MARKDOWN_COMPONENTS}
+ urlTransform={safeMarkdownUrl}
controls={false}
className={markdownClassName}
>
diff --git a/studio/frontend/src/features/chat/components/research-message.tsx b/studio/frontend/src/features/chat/components/research-message.tsx
index 563cacd009..a2f5520637 100644
--- a/studio/frontend/src/features/chat/components/research-message.tsx
+++ b/studio/frontend/src/features/chat/components/research-message.tsx
@@ -1,19 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
-import { MarkdownPreview } from "@/components/markdown/markdown-preview";
+import type { Citation } from "@/components/assistant-ui/citation-utils";
+import { DocumentSourcesGroup } from "@/components/assistant-ui/rag-sources";
import {
type SourceData,
SourcesGroup,
} from "@/components/assistant-ui/sources";
+import { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
-import {
- Check,
- Telescope,
- TriangleAlert,
-} from "lucide-react";
+import { Check, Telescope, TriangleAlert } from "lucide-react";
import { type ReactElement, useEffect } from "react";
import {
ensureResearchRunFollowed,
@@ -76,6 +74,21 @@ export function ResearchMessage(): ReactElement {
title: source.title || source.url,
description: source.snippet ?? undefined,
}));
+ const documentSources: Citation[] = (run.documentSources ?? []).map(
+ (source, index) => ({
+ id: source.chunkId ?? String(source.id ?? index),
+ filename: source.filename,
+ page: source.page,
+ score: source.score,
+ text: source.snippet ?? "",
+ documentId: source.documentId,
+ chunkId: source.chunkId,
+ }),
+ );
+ const documentCount = new Set(
+ documentSources.map((source) => source.documentId ?? source.filename),
+ ).size;
+ const sourceCount = sources.length + documentCount;
return (
+
);
}
diff --git a/studio/frontend/src/features/chat/types/research.ts b/studio/frontend/src/features/chat/types/research.ts
index d3bf68efa8..0fd42c3a14 100644
--- a/studio/frontend/src/features/chat/types/research.ts
+++ b/studio/frontend/src/features/chat/types/research.ts
@@ -62,6 +62,12 @@ export interface ResearchSource {
fetchedAt?: number;
}
+export interface ResearchDocumentSource extends ResearchEvidenceSource {
+ id?: string | number;
+ stepPosition?: number | null;
+ fetchedAt?: number;
+}
+
export interface ResearchInferenceRequest {
model: string;
temperature?: number;
@@ -104,6 +110,7 @@ export interface ResearchRun {
planHash: string | null;
steps: ResearchStepSnapshot[];
sources: ResearchSource[];
+ documentSources?: ResearchDocumentSource[];
config?: {
model?: string;
inferenceRequest?: Record;
diff --git a/studio/frontend/src/lib/safe-markdown-url.ts b/studio/frontend/src/lib/safe-markdown-url.ts
new file mode 100644
index 0000000000..6f4a175e37
--- /dev/null
+++ b/studio/frontend/src/lib/safe-markdown-url.ts
@@ -0,0 +1,33 @@
+import { type UrlTransform, defaultUrlTransform } from "streamdown";
+
+const PROTOCOL_RELATIVE_RE = /^[/\\]{2}/;
+const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;
+
+function stripAsciiControls(value: string): string {
+ return Array.from(value, (character) => {
+ const code = character.charCodeAt(0);
+ return code <= 0x1f || code === 0x7f ? "" : character;
+ }).join("");
+}
+
+export const safeMarkdownUrl: UrlTransform = (url, key, node) => {
+ if (node.tagName !== "img") {
+ return defaultUrlTransform(url, key, node);
+ }
+
+ // Browsers discard ASCII controls while parsing URLs, so strip them before
+ // rejecting remote schemes and protocol-relative image locations.
+ const normalized = stripAsciiControls(url).trim();
+ const lower = normalized.toLowerCase();
+
+ if (lower.startsWith("data:") || lower.startsWith("blob:")) {
+ return normalized;
+ }
+ if (PROTOCOL_RELATIVE_RE.test(normalized)) {
+ return null;
+ }
+ if (SCHEME_RE.test(normalized)) {
+ return null;
+ }
+ return normalized;
+};
diff --git a/tests/studio/test_deep_research_frontend_contract.py b/tests/studio/test_deep_research_frontend_contract.py
index 2a22257dd9..55a33ff24e 100644
--- a/tests/studio/test_deep_research_frontend_contract.py
+++ b/tests/studio/test_deep_research_frontend_contract.py
@@ -86,6 +86,8 @@ def test_research_presentation_is_integrated() -> None:
store = source("features/chat/stores/chat-runtime-store.ts")
activity = source("features/chat/components/research-activity-panel.tsx")
message = source("features/chat/components/research-message.tsx")
+ markdown_preview = source("components/markdown/markdown-preview.tsx")
+ safe_markdown_url = source("lib/safe-markdown-url.ts")
coordinator = source("features/chat/stores/research-run-store.ts")
assert "DeepResearchComposerButton" in thread
assert "Deep research" in thread
@@ -103,6 +105,9 @@ def test_research_presentation_is_integrated() -> None:
assert "Stop research" not in activity
assert "retryResearchRun" in activity
assert "Deep research completed" in message
+ assert "