mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-21 05:14:45 +08:00
feat(frontend): unify citation logic and prevent half-finished citations
- Add SafeCitationContent as single component for citation-aware body: useParsedCitations + shouldShowCitationLoading; show loading until citations complete, then render body with createCitationMarkdownComponents. Supports optional remarkPlugins, rehypePlugins, isHuman, img. - Refactor MessageListItem: assistant message body now uses SafeCitationContent only; remove duplicate useParsedCitations, shouldShowCitationLoading, createCitationMarkdownComponents and CitationsLoadingIndicator logic. Human messages keep plain AIElementMessageResponse (no citation parsing). - Use SafeCitationContent for clarification, present-files (message-list), thinking steps and write_file loading (message-group), subtask result (subtask-card). Artifact markdown preview keeps same guard (shouldShowCitationLoading) with ArtifactFilePreview. - Unify loading condition: shouldShowCitationLoading(rawContent, cleanContent, isLoading) is the single source of truth. Show loading when (isLoading && hasCitationsBlock(rawContent)) or when (hasCitationsBlock(rawContent) && hasUnreplacedCitationRefs(cleanContent)) so Pro/Ultra modes also show "loading citations" and half-finished [cite-N] never appear. - message-group write_file: replace hasCitationsBlock + threadIsLoading with shouldShowCitationLoading(fileContent, cleanContent, threadIsLoading && isLast) for consistency. - citations/utils: parse incomplete <citations> during streaming; remove isCitationsBlockIncomplete; keep hasUnreplacedCitationRefs internal; document display rule in file header. Co-authored-by: Cursor <cursoragent@cursor.com> --- feat(前端): 统一引用逻辑并杜绝半成品引用 - 新增 SafeCitationContent 作为引用正文的唯一出口:内部使用 useParsedCitations + shouldShowCitationLoading,在引用未就绪时只显示 「正在整理引用」,就绪后用 createCitationMarkdownComponents 渲染正文; 支持可选 remarkPlugins、rehypePlugins、isHuman、img。 - 重构 MessageListItem:助手消息正文仅通过 SafeCitationContent 渲染, 删除重复的 useParsedCitations、shouldShowCitationLoading、 createCitationMarkdownComponents、CitationsLoadingIndicator 等逻辑; 用户消息仍用 AIElementMessageResponse,不做引用解析。 - 澄清、present-files(message-list)、思考步骤与 write_file 加载 (message-group)、子任务结果(subtask-card)均使用 SafeCitationContent;Artifact 的 markdown 预览仍用同一 guard shouldShowCitationLoading,正文由 ArtifactFilePreview 渲染。 - 统一加载条件:shouldShowCitationLoading(rawContent, cleanContent, isLoading) 为唯一判断。在「流式中且已有引用块」或「有引用块且 cleanContent 中仍有未替换的 [cite-N]」时仅显示加载,从而在 Pro/Ultra 下也能看到「正在整理引用」,且永不出现半成品 [cite-N]。 - message-group 的 write_file:用 shouldShowCitationLoading( fileContent, cleanContent, threadIsLoading && isLast) 替代 hasCitationsBlock + threadIsLoading,与其他场景一致。 - citations/utils:流式时解析未闭合的 <citations>;移除 isCitationsBlockIncomplete;hasUnreplacedCitationRefs 保持内部使用; 在文件头注释中说明展示规则。
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Citation parsing and display helpers.
|
||||
* Display rule: never show half-finished citations. Use shouldShowCitationLoading
|
||||
* and show only the loading indicator until the block is complete and all
|
||||
* [cite-N] refs are replaced.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Citation data structure representing a source reference
|
||||
*/
|
||||
@@ -16,8 +23,42 @@ export interface ParseCitationsResult {
|
||||
cleanContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse citation lines (one JSON object per line) into Citation array.
|
||||
* Deduplicates by URL. Used for both complete and incomplete (streaming) blocks.
|
||||
*/
|
||||
function parseCitationLines(
|
||||
blockContent: string,
|
||||
seenUrls: Set<string>,
|
||||
): Citation[] {
|
||||
const out: Citation[] = [];
|
||||
const lines = blockContent.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed?.startsWith("{")) continue;
|
||||
try {
|
||||
const citation = JSON.parse(trimmed) as Citation;
|
||||
if (citation.id && citation.url && !seenUrls.has(citation.url)) {
|
||||
seenUrls.add(citation.url);
|
||||
out.push({
|
||||
id: citation.id,
|
||||
title: citation.title || "",
|
||||
url: citation.url,
|
||||
snippet: citation.snippet || "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines - can happen during streaming
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse citations block from message content.
|
||||
* Shared by all modes (Flash / Thinking / Pro / Ultra); supports incomplete
|
||||
* <citations> blocks during SSE streaming (parses whatever complete JSON lines
|
||||
* have arrived so far so [cite-N] can be linked progressively).
|
||||
*
|
||||
* The citations block format:
|
||||
* <citations>
|
||||
@@ -33,41 +74,25 @@ export function parseCitations(content: string): ParseCitationsResult {
|
||||
return { citations: [], cleanContent: content };
|
||||
}
|
||||
|
||||
// Match ALL citations blocks anywhere in content (not just at the start)
|
||||
const citationsRegex = /<citations>([\s\S]*?)<\/citations>/g;
|
||||
const citations: Citation[] = [];
|
||||
const seenUrls = new Set<string>(); // Deduplicate by URL
|
||||
let cleanContent = content;
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
// 1) Complete blocks: <citations>...</citations>
|
||||
const citationsRegex = /<citations>([\s\S]*?)<\/citations>/g;
|
||||
let match;
|
||||
while ((match = citationsRegex.exec(content)) !== null) {
|
||||
const citationsBlock = match[1] ?? "";
|
||||
citations.push(...parseCitationLines(match[1] ?? "", seenUrls));
|
||||
}
|
||||
|
||||
// Parse each line as JSON
|
||||
const lines = citationsBlock.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed?.startsWith("{")) {
|
||||
try {
|
||||
const citation = JSON.parse(trimmed) as Citation;
|
||||
// Validate required fields and deduplicate
|
||||
if (citation.id && citation.url && !seenUrls.has(citation.url)) {
|
||||
seenUrls.add(citation.url);
|
||||
citations.push({
|
||||
id: citation.id,
|
||||
title: citation.title || "",
|
||||
url: citation.url,
|
||||
snippet: citation.snippet || "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines - this can happen during streaming
|
||||
}
|
||||
}
|
||||
// 2) Incomplete block during streaming: <citations>... (no closing tag yet)
|
||||
if (content.includes("<citations>") && !content.includes("</citations>")) {
|
||||
const openMatch = content.match(/<citations>([\s\S]*)$/);
|
||||
if (openMatch?.[1] != null) {
|
||||
citations.push(...parseCitationLines(openMatch[1], seenUrls));
|
||||
}
|
||||
}
|
||||
|
||||
cleanContent = removeCitationsBlocks(content);
|
||||
let cleanContent = removeCitationsBlocks(content);
|
||||
|
||||
// Convert [cite-N] references to markdown links
|
||||
// Example: [cite-1] -> [Title](url)
|
||||
@@ -95,13 +120,6 @@ export function parseCitations(content: string): ParseCitationsResult {
|
||||
return { citations, cleanContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return content with citations block removed and [cite-N] replaced by markdown links.
|
||||
*/
|
||||
export function getCleanContent(content: string): string {
|
||||
return parseCitations(content ?? "").cleanContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map from URL to Citation for quick lookup
|
||||
*
|
||||
@@ -173,15 +191,32 @@ export function hasCitationsBlock(content: string): boolean {
|
||||
return Boolean(content?.includes("<citations>"));
|
||||
}
|
||||
|
||||
/** Pattern for [cite-1], [cite-2], ... that should be replaced by parseCitations. */
|
||||
const UNREPLACED_CITE_REF = /\[cite-\d+\]/;
|
||||
|
||||
/**
|
||||
* Check if content is still receiving the citations block (streaming)
|
||||
* This helps determine if we should wait before parsing
|
||||
*
|
||||
* @param content - The current content being streamed
|
||||
* @returns true if citations block appears to be incomplete
|
||||
* Whether cleanContent still contains unreplaced [cite-N] refs (half-finished citations).
|
||||
* When true, callers must not render this content and should show loading instead.
|
||||
*/
|
||||
export function isCitationsBlockIncomplete(content: string): boolean {
|
||||
return hasCitationsBlock(content) && !content.includes("</citations>");
|
||||
export function hasUnreplacedCitationRefs(cleanContent: string): boolean {
|
||||
return Boolean(cleanContent && UNREPLACED_CITE_REF.test(cleanContent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth: true when body must not be rendered (show loading instead).
|
||||
* Use after parseCitations: pass raw content, parsed cleanContent, and isLoading.
|
||||
* When streaming and any citation block is present, show loading so the indicator
|
||||
* is visible in all modes (Pro/Ultra often receive complete blocks in one chunk).
|
||||
*/
|
||||
export function shouldShowCitationLoading(
|
||||
rawContent: string,
|
||||
cleanContent: string,
|
||||
isLoading: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
(isLoading && hasCitationsBlock(rawContent)) ||
|
||||
(hasCitationsBlock(rawContent) && hasUnreplacedCitationRefs(cleanContent))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user