From 7194fe942c4b0473597e14d67dfb067081773be4 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Wed, 14 Oct 2026 02:12:07 +0800 Subject: [PATCH 1/8] feat(chat): persist citation metadata and guard AIDP downloads --- backend/database/conversation_db.py | 9 + backend/database/db_models.py | 8 + backend/management/services/agent/run.py | 6 + .../conversation_management_service.py | 12 +- .../v2.7.1_002_chat_source_snapshots.sql | 9 + .../chat/components/chatRightPanel.tsx | 32 ++- .../conversation-thread-list-adapter.tsx | 75 +++-- .../adapter/remote-chat-model-adapter.ts | 54 +++- .../[locale]/newchat/assistant-ui/thread.tsx | 8 +- .../app/[locale]/newchat/ui/markdown-text.tsx | 34 ++- .../app/[locale]/newchat/ui/sources-panel.tsx | 86 +++--- .../app/[locale]/newchat/ui/tool-fallback.tsx | 13 +- .../common/sourceMetadata.module.css | 112 ++++++++ frontend/components/common/sourceMetadata.tsx | 264 ++++++++++++++++++ .../shared/services/aidpCitationService.ts | 24 ++ frontend/lib/source.ts | 100 +++++++ frontend/public/locales/en/common.json | 9 + frontend/public/locales/zh/common.json | 7 + .../workbench/StreamCancellation.test.ts | 233 +++++++++++++++- frontend/types/source.ts | 19 ++ .../ext_components/aidp/aidp_search_tool.py | 15 +- sdk/nexent/core/utils/observer.py | 8 + .../UT-FE-AUTO-82166F602745F6D7/case.yaml | 36 ++- .../execution.yaml | 20 ++ .../UT-FE-AUTO-82166F602745F6D7/test.test.tsx | 119 +++++++- .../CHAT-AIDP-SOURCES-20261014.yaml | 32 +++ test-e2e/features/F-065/feature.yaml | 4 + test/backend/database/test_conversation_db.py | 10 + .../services/test_agent_run_final_answers.py | 24 ++ .../test_conversation_management_service.py | 23 +- .../mock_servers/aidp_mgmt_mock_server.py | 13 +- .../aidp/test_aidp_citation_identity.py | 37 +++ .../aidp/test_aidp_search_tool.py | 2 + 33 files changed, 1352 insertions(+), 105 deletions(-) create mode 100644 deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql create mode 100644 frontend/components/common/sourceMetadata.module.css create mode 100644 frontend/components/common/sourceMetadata.tsx create mode 100644 frontend/ext_components/aidp/shared/services/aidpCitationService.ts create mode 100644 frontend/lib/source.ts create mode 100644 frontend/types/source.ts create mode 100644 test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml create mode 100644 test/ext_components/aidp/test_aidp_citation_identity.py diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index cb40c759c3..6c74db5736 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -63,6 +63,8 @@ class SearchRecord(TypedDict): score_accuracy: Optional[float] score_semantic: Optional[float] retrieval_highlight_terms: Optional[List[str]] + source_metadata: Optional[Dict[str, Any]] + source_reference: Optional[Dict[str, Any]] published_date: Optional[datetime] cite_index: Optional[int] search_type: Optional[str] @@ -476,6 +478,9 @@ def persist_assistant_run_batch( "score_overall": record.get("score_overall"), "score_accuracy": record.get("score_accuracy"), "score_semantic": record.get("score_semantic"), + "retrieval_highlight_terms": record.get("retrieval_highlight_terms"), + "source_metadata": record.get("source_metadata"), + "source_reference": record.get("source_reference"), "published_date": record.get("published_date"), "cite_index": record.get("cite_index"), "search_type": record.get("search_type"), @@ -1949,6 +1954,10 @@ def create_source_search(search_data: Dict[str, Any], user_id: Optional[str] = N ] if 'published_date' in search_data: data["published_date"] = search_data['published_date'] + if 'source_metadata' in search_data: + data["source_metadata"] = search_data['source_metadata'] + if 'source_reference' in search_data: + data["source_reference"] = search_data['source_reference'] if user_id: data = add_creation_tracking(data, user_id) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 314e31d4ca..e1ac743825 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -352,6 +352,14 @@ class ConversationSourceSearch(TableBase): JSONB, doc="Exact lexical terms returned by the retrieval engine for source highlighting", ) + source_metadata = Column( + JSONB, nullable=True, + comment="Opaque business metadata snapshot returned for this retrieval hit", + ) + source_reference = Column( + JSONB, nullable=True, + comment="Technical retrieval attributes for this hit, excluding business metadata", + ) published_date = Column(TIMESTAMP( timezone=False), doc="Upload date of local files or network search date") cite_index = Column( diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 32b061547a..405564eb28 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -692,6 +692,12 @@ async def _iter_run_chunks(): "source_title": result.get("title", ""), "source_location": result.get("url", ""), "source_content": result.get("text", ""), + "source_metadata": result.get("score_details", {}).get("metadata"), + "source_reference": { + key: value + for key, value in result.get("score_details", {}).items() + if key != "metadata" + }, "score_overall": float(result.get("score")) if result.get("score") not in (None, "") else None, diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index adb4d6e6c2..d7f57e7a49 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -829,6 +829,10 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List "cite_index": record["cite_index"], "search_type": record["search_type"], "tool_sign": record["tool_sign"], "score_details": {}} + search_item["score_details"].update(record.get("source_reference") or {}) + if record.get("source_metadata") is not None: + search_item["score_details"]["metadata"] = record["source_metadata"] + if record["score_accuracy"] is not None: search_item["score_details"]["accuracy"] = record["score_accuracy"] if record["score_semantic"] is not None: @@ -1091,10 +1095,16 @@ def get_sources_service(conversation_id: Optional[int], message_id: Optional[int "filename": record["source_title"] if record["source_type"] == "file" else None, "published_date": record["published_date"].strftime("%Y-%m-%d") if record[ "published_date"] else None, - "score": record["score_overall"] + "score": record["score_overall"], + "cite_index": record.get("cite_index"), + "search_type": record.get("search_type"), + "tool_sign": record.get("tool_sign"), } search_item["score_details"] = {} + search_item["score_details"].update(record.get("source_reference") or {}) + if record.get("source_metadata") is not None: + search_item["score_details"]["metadata"] = record["source_metadata"] if record["score_accuracy"] is not None: search_item["score_details"]["accuracy"] = record["score_accuracy"] if record["score_semantic"] is not None: diff --git a/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql new file mode 100644 index 0000000000..ebfd2aa192 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql @@ -0,0 +1,9 @@ +-- Preserve the original per-hit attributes without changing existing source identities. +ALTER TABLE nexent.conversation_source_search_t + ADD COLUMN IF NOT EXISTS source_metadata JSONB, + ADD COLUMN IF NOT EXISTS source_reference JSONB; + +COMMENT ON COLUMN nexent.conversation_source_search_t.source_metadata + IS 'Opaque business metadata snapshot returned for this retrieval hit'; +COMMENT ON COLUMN nexent.conversation_source_search_t.source_reference + IS 'Technical retrieval attributes for this hit, excluding business metadata'; diff --git a/frontend/app/[locale]/chat/components/chatRightPanel.tsx b/frontend/app/[locale]/chat/components/chatRightPanel.tsx index f074a443f2..309af2f5e1 100644 --- a/frontend/app/[locale]/chat/components/chatRightPanel.tsx +++ b/frontend/app/[locale]/chat/components/chatRightPanel.tsx @@ -25,6 +25,9 @@ import { } from "@/services/storageService"; import { message, Button } from "antd"; import log from "@/lib/logger"; +import { getAidpDownloadLocator, getSourceAttributes, getSourceLabel, isAidpSource } from "@/lib/source"; +import { SourceMetadata } from "@/components/common/sourceMetadata"; +import { downloadAidpCitation } from "@/ext_components/aidp/shared/services/aidpCitationService"; import { useConfig } from "@/hooks/useConfig"; import type { AppConfig } from "@/types/modelConfig"; @@ -80,6 +83,9 @@ function SearchResultItem({ const source_type = result.source_type || "url"; const filename = result.filename || result.title || ""; const searchType = result.search_type || ""; + const sourceAttributes = { ...getSourceAttributes(searchType, result.score_details), toolSign: result.tool_sign }; + const aidpLocator = getAidpDownloadLocator(sourceAttributes); + const isAidp = isAidpSource(sourceAttributes) || source_type === "aidp"; const isKnowledgeResult = source_type === "file" || source_type === "datamate" || @@ -141,13 +147,15 @@ function SearchResultItem({ if (source_type === "datamate") { return t("chatRightPanel.source.datamate", "Source: Datamate"); } - if (source_type === "aidp" || searchType === "aidp_search") { + if (isAidp) { return t("chatRightPanel.source.aidp", "Source: AIDP"); } - if (source_type === "file") { - return t("chatRightPanel.source.nexent", "Source: Nexent"); - } - return ""; + return getSourceLabel({ ...sourceAttributes, sourceType: source_type, url }, { + knowledgeBase: t("chat.sources.knowledgeBase"), + web: t("chat.sources.web"), + document: t("chat.sources.document"), + source: t("chat.sources.source"), + }); }; const downloadDatamateFile = async () => { @@ -218,6 +226,8 @@ function SearchResultItem({ e.preventDefault(); e.stopPropagation(); + if (isAidp && !aidpLocator) return; + if (!filename && !url) { message.error( t("chatRightPanel.fileDownloadError", "File name or URL is missing") @@ -227,6 +237,10 @@ function SearchResultItem({ setIsDownloading(true); try { + if (isAidp && aidpLocator) { + await downloadAidpCitation(aidpLocator, filename || "download"); + return; + } if (source_type === "datamate") { await downloadDatamateFile(); return; @@ -276,7 +290,7 @@ function SearchResultItem({ {title} ); - } else if (isKnowledgeResult) { + } else if (isKnowledgeResult && (!isAidp || aidpLocator)) { titleNode = ( + +
-
{filename || formatUrl(result)} : {filename || formatUrl(result)} - + }
diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 2a04858952..85891de7f0 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -2,6 +2,7 @@ import type { FC, MutableRefObject, PropsWithChildren } from "react"; import { useMemo } from "react"; +import { getSourceAttributes } from "@/lib/source"; import { ExportedMessageRepository, RuntimeAdapterProvider, @@ -187,7 +188,7 @@ const toToolSearchItem = (value: unknown) => { const toolSign = typeof item.tool_sign === "string" ? item.tool_sign : undefined; - return resolvedUrl || sourceFile + return resolvedUrl || sourceFile || filename || title ? { url: resolvedUrl, title, @@ -199,6 +200,8 @@ const toToolSearchItem = (value: unknown) => { sourceType: typeof item.source_type === "string" ? item.source_type : undefined, filename: filename || undefined, + publishedDate: typeof item.published_date === "string" ? item.published_date : undefined, + downloadUrl: typeof item.download_url === "string" ? item.download_url : undefined, sourceFile: sourceFile || imageMetadata?.source_file || undefined, objectName: typeof item.object_name === "string" ? item.object_name : undefined, @@ -206,6 +209,7 @@ const toToolSearchItem = (value: unknown) => { toolSign, isImage: Boolean(imageMetadata), retrievalHighlightTerms: getRetrievalHighlightTerms(item.score_details), + ...getSourceAttributes(item.search_type, item.score_details), } : null; }; @@ -449,6 +453,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { retrievalHighlightTerms: getRetrievalHighlightTerms( item.score_details ), + ...getSourceAttributes(item.search_type, item.score_details), }); } } @@ -588,6 +593,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { ) : []; const restoredImageUrls = new Set(); + const restoredImageKeys = new Set(); const restoredImages: any[] = []; const historicalSkillFiles = new Map(); let historicalSkillSummaryPart: { type: "text"; text: string } | null = @@ -597,37 +603,56 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { { files: Map; summary: string | null } >(); const appendHistoricalImage = (imageUrl: string) => { - if (!imageUrl || restoredImageUrls.has(imageUrl)) return; + if (!imageUrl || restoredImageUrls.has(imageUrl)) return []; const imageIndex = restoredImageUrls.size; const imageKey = answerImageKeys[imageIndex]; - const metadata = persistedImageSources.find((searchItem) => { + const matchingSources = persistedImageSources.filter((searchItem) => { + const item = searchItem as Record; + return item.url === imageUrl; + }) as Record[]; + const fallbackMetadata = persistedImageSources.find((searchItem) => { const item = searchItem as Record; return ( imageKey === `${item.tool_sign ?? ""}${item.cite_index ?? ""}` ); }) as Record | undefined; - const title = (metadata?.title as string | undefined) || imageUrl; - const imagePart: any = { - type: "source", - sourceType: "url", - url: imageUrl, - title, - text: metadata?.text as string | undefined, - citeIndex: - (metadata?.cite_index as number | undefined) ?? undefined, - isImage: true, - imageKey: + const appendedImages = []; + for (const metadata of matchingSources.length + ? matchingSources + : [fallbackMetadata]) { + const sourceImageKey = (metadata?.image_key as string | undefined) || (metadata?.tool_sign && metadata?.cite_index !== undefined ? `${metadata.tool_sign}${metadata.cite_index}` : undefined) || - imageKey, - }; - const meta = buildMetadata(); - if (meta) imagePart.metadata = meta; + imageKey; + if (sourceImageKey && restoredImageKeys.has(sourceImageKey)) continue; + const title = (metadata?.title as string | undefined) || imageUrl; + const imagePart: any = { + ...getSourceAttributes( + metadata?.search_type, + metadata?.score_details + ), + type: "source", + sourceType: "url", + url: imageUrl, + title, + text: metadata?.text as string | undefined, + filename: metadata?.filename as string | undefined, + toolSign: metadata?.tool_sign as string | undefined, + citeIndex: + (metadata?.cite_index as number | undefined) ?? undefined, + isImage: true, + imageKey: sourceImageKey, + }; + const meta = buildMetadata(); + if (meta) imagePart.metadata = meta; + if (sourceImageKey) restoredImageKeys.add(sourceImageKey); + restoredImages.push(imagePart); + appendedImages.push(imagePart); + } restoredImageUrls.add(imageUrl); - restoredImages.push(imagePart); - return imagePart; + return appendedImages; }; for (const [partIndex, part] of messageParts.entries()) { @@ -783,8 +808,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { if (part.type === "picture_web") { for (const imageUrl of parseSearchImageUrls(part.content)) { - const imagePart = appendHistoricalImage(imageUrl); - if (imagePart) { + for (const imagePart of appendHistoricalImage(imageUrl)) { attachSearchContentToTool( content, { @@ -793,6 +817,11 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { text: imagePart.text, isImage: true, imageKey: imagePart.imageKey, + searchType: imagePart.searchType, + sourceMetadata: imagePart.sourceMetadata, + sourceReference: imagePart.sourceReference, + toolSign: imagePart.toolSign, + citeIndex: imagePart.citeIndex, }, part.tool_call_id ); @@ -1253,6 +1282,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { title, text: item.text as string | undefined, filename, + publishedDate: item.published_date as string | undefined, downloadUrl: item.download_url as string | undefined, objectName: item.object_name as string | undefined, citeIndex, @@ -1260,6 +1290,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { retrievalHighlightTerms: getRetrievalHighlightTerms( item.score_details ), + ...getSourceAttributes(item.search_type, item.score_details), messageId, }); } diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 8c47486b0e..02e69502b6 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -14,6 +14,8 @@ import { ApiError } from "@/services/api"; import { notifyWorkbenchConfigResolved } from "@/features/workbench/runtimeEvents"; import log from "@/lib/logger"; import { completeTrailingToolCalls } from "@/lib/toolCallStatus"; +import type { SourceAttributes } from "@/types/source"; +import { getSourceAttributes } from "@/lib/source"; import { appendClarificationPart, isClarificationFallback, @@ -953,7 +955,7 @@ export function attachExecutionLogsToTool( */ export function attachSearchContentToTool( contentParts: any[], - item: { + item: SourceAttributes & { url: string; title: string; text?: string; @@ -982,7 +984,7 @@ export function attachSearchContentToTool( if (!targetToolCall.searchContent) { targetToolCall.searchContent = []; } - if (item.url || item.sourceFile) { + if (item.url || item.sourceFile || item.filename || item.title) { targetToolCall.searchContent.push(item); } return true; @@ -1013,7 +1015,7 @@ function findMostRecentToolCall( // Global registry for search sources by message ID (used by MarkdownText for [[b1]] rendering) // Keyed by messageId (from message.id in the stream), value is SearchSource[] -export interface SearchSource { +export interface SearchSource extends SourceAttributes { citeIndex: number; url: string; title: string; @@ -2216,12 +2218,20 @@ export const remoteChatModelAdapter: ChatModelAdapter = { for (const imageUrl of imageUrls) { if (!imageUrl) continue; - const metadata = imageMetadata[searchImagesAccumulator.length]; + const metadata = imageMetadata.find( + (source) => + source.url === imageUrl && + !searchImagesAccumulator.some( + (image) => image.imageKey === source.imageKey + ) + ); const imageSource: SearchSource = { ...metadata, url: imageUrl, title: metadata?.title || imageUrl, text: metadata?.text, + citeIndex: metadata?.citeIndex ?? 0, + toolSign: metadata?.toolSign ?? "", sourceType: "url", isImage: true, imageKey: @@ -2802,6 +2812,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }); } attachSearchContentToTool( @@ -2822,6 +2836,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }, chunk.tool_call_id ); @@ -3129,6 +3147,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }); } attachSearchContentToTool( @@ -3149,6 +3171,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }, chunk.tool_call_id ); @@ -3200,13 +3226,14 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const imageMetadata = searchSourcesAccumulator.filter( (source) => source.isImage ); + const matchedImageSources = new Set(); for (const [index, image] of searchImagesAccumulator.entries()) { - const metadata = - imageMetadata.find((source) => { - if (!source.url) return false; - const relativeUrl = source.url.replace(/^\/+/, ""); - return image.url.endsWith(relativeUrl); - }) ?? imageMetadata[index]; + const metadata = imageMetadata.find((source) => { + if (!source.url || matchedImageSources.has(source)) return false; + const relativeUrl = source.url.replace(/^\/+/, ""); + return image.url.endsWith(relativeUrl); + }); + if (metadata) matchedImageSources.add(metadata); const imageKey = metadata?.imageKey || (metadata?.toolSign && metadata.citeIndex !== undefined @@ -3274,6 +3301,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { citeIndex: source.citeIndex, toolSign: source.toolSign, retrievalHighlightTerms: source.retrievalHighlightTerms, + searchType: source.searchType, + sourceMetadata: source.sourceMetadata, + sourceReference: source.sourceReference, + publishedDate: source.publishedDate, messageId, // used by thread.tsx / MarkdownText to look up from registry }); } @@ -3290,6 +3321,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { toolSign: image.toolSign, isImage: true, imageKey: image.imageKey, + searchType: image.searchType, + sourceMetadata: image.sourceMetadata, + sourceReference: image.sourceReference, messageId, }); } diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 4550610bb9..27cf286510 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -1,5 +1,7 @@ "use client"; +import type { SourceAttributes } from "@/types/source"; + import { useCallback, useEffect, @@ -2064,7 +2066,7 @@ const UserActionBar: FC = () => { * detect the synthetic `isImage` flag on picture_web entries. The base shape * stays compatible with `@assistant-ui/react`'s `SourceMessagePartComponent`. */ -interface SourcePartLike { +interface SourcePartLike extends SourceAttributes { type: "source"; sourceType?: "url" | "document"; url?: string; @@ -2323,6 +2325,10 @@ const SourceGroupButton: FC = ({ indices }) => { isImage: source.isImage, citeIndex: source.citeIndex, toolSign: source.toolSign, + searchType: source.searchType, + sourceMetadata: source.sourceMetadata, + sourceReference: source.sourceReference, + retrievalHighlightTerms: source.retrievalHighlightTerms, })) : groupedSources; for (const item of displaySources) { diff --git a/frontend/app/[locale]/newchat/ui/markdown-text.tsx b/frontend/app/[locale]/newchat/ui/markdown-text.tsx index d29339e952..c5a270d94d 100644 --- a/frontend/app/[locale]/newchat/ui/markdown-text.tsx +++ b/frontend/app/[locale]/newchat/ui/markdown-text.tsx @@ -1,5 +1,8 @@ "use client"; +import type { SourceAttributes } from "@/types/source"; +import { getSourceLabel } from "@/lib/source"; + import "@assistant-ui/react-markdown/styles/dot.css"; import { @@ -43,7 +46,7 @@ import { * Checks searchSourcesRegistry first (streaming messages), then * conversationSourcesRegistry (historical messages). */ -interface MessageSourcePart { +interface MessageSourcePart extends SourceAttributes { type?: string; sourceType?: string; publishedDate?: string; @@ -98,6 +101,9 @@ function resolveCiteSources( isImage: part.isImage, imageKey: part.imageKey, retrievalHighlightTerms: part.retrievalHighlightTerms, + searchType: part.searchType, + sourceMetadata: part.sourceMetadata, + sourceReference: part.sourceReference, }]; }); @@ -132,22 +138,6 @@ function findCiteSource(sources: SearchSource[], citekey: string): SearchSource : undefined; } -const extractDomain = (url: string): string => { - try { - return new URL(url).hostname.replace(/^www\./, ""); - } catch { - return url; - } -}; - -function getSourceLabel(source: PanelSourceItem | undefined): string | undefined { - if (!source) return undefined; - if (source.sourceType === "document" || !source.url) { - return "来源: Nexent"; - } - return `来源: ${extractDomain(source.url)}`; -} - function buildCitationDisplayIndexMap( content: readonly MessageSourcePart[] ): Map { @@ -184,6 +174,9 @@ function toPanelSource(source: SearchSource): PanelSourceItem { toolSign: source.toolSign, isImage: source.isImage, retrievalHighlightTerms: source.retrievalHighlightTerms, + searchType: source.searchType, + sourceMetadata: source.sourceMetadata, + sourceReference: source.sourceReference, }; } @@ -319,7 +312,12 @@ const CiteComponent: FC< filename={panelSource?.filename} url={panelSource?.url} sourceType={panelSource?.sourceType} - sourceLabel={getSourceLabel(panelSource)} + sourceLabel={panelSource ? getSourceLabel(panelSource, { + knowledgeBase: t("chat.sources.knowledgeBase"), + web: t("chat.sources.web"), + document: t("chat.sources.document"), + source: t("chat.sources.source"), + }) : undefined} loading={!source} onClick={ source && messageId diff --git a/frontend/app/[locale]/newchat/ui/sources-panel.tsx b/frontend/app/[locale]/newchat/ui/sources-panel.tsx index 2786e5d847..49798e1f92 100644 --- a/frontend/app/[locale]/newchat/ui/sources-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/sources-panel.tsx @@ -15,7 +15,6 @@ import { FileTextIcon, ImageIcon, LoaderCircleIcon, - ServerIcon, XIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -37,13 +36,18 @@ import { storageService, } from "@/services/storageService"; import { AuthenticatedImage } from "./authenticated-image"; +import type { SourceAttributes } from "@/types/source"; +import { getAidpDownloadLocator, getSourceLabel, getSourceProvider, isAidpSource } from "@/lib/source"; +import { SourceMetadata } from "@/components/common/sourceMetadata"; +import { downloadAidpCitation } from "@/ext_components/aidp/shared/services/aidpCitationService"; +import log from "@/lib/logger"; /** * Loose typing for the source items handled by the side panel. Matches the * shape of `SourcePartLike` used in `thread.tsx` so we can render each entry * consistently with the inline preview. */ -export interface PanelSourceItem { +export interface PanelSourceItem extends SourceAttributes { sourceType?: "url" | "document"; url?: string; title?: string; @@ -143,7 +147,7 @@ export const SourcesPanel: FC = ({ aria-label={t("chat.sources.panel")} >
-

{t("chatRightPanel.searchTitle")}

+

{t("chat.sources.referenceTitle", { count: sources.length })}

-
0 &&
= ({ active={!showSources} onClick={() => setActiveTab("images")} /> -
+
}
{currentItems.length === 0 ? ( @@ -188,7 +192,7 @@ export const SourcesPanel: FC = ({ ))} @@ -277,7 +281,7 @@ const SourceSummary: FC<{ if (!text?.trim()) return null; - if (highlighted) { + if (highlighted && highlightTerms.length > 0) { return (

@@ -291,7 +295,7 @@ const SourceSummary: FC<{ } return ( -

+

{text}

); @@ -301,21 +305,14 @@ const SourceFooter: FC<{ item: PanelSourceItem; sourceLabel: string }> = ({ item, sourceLabel, }) => { - const fileLabel = item.filename || item.title || item.url || ""; - if (!fileLabel) return null; - return ( -
+
- {item.sourceType === "document" ? ( + {getSourceProvider(item.searchType, item.toolSign) || item.sourceType === "document" ? ( ) : ( )} - {fileLabel} -
-
- {sourceLabel}
@@ -332,8 +329,7 @@ const SourceCardBody: FC<{ item: PanelSourceItem; selected: boolean; highlightTerms: string[]; - sourceLabel: string; -}> = ({ title, dateFallback, item, selected, highlightTerms, sourceLabel }) => ( +}> = ({ title, dateFallback, item, selected, highlightTerms }) => ( <>
{title} @@ -354,7 +350,6 @@ const SourceCardBody: FC<{ highlighted={selected} highlightTerms={highlightTerms} /> - ); @@ -371,6 +366,14 @@ const SourceListItem: FC<{ const itemRef = useRef(null); const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); + const aidpLocator = getAidpDownloadLocator(item); + const isAidp = isAidpSource(item); + const sourceLabel = getSourceLabel(item, { + knowledgeBase: t("chat.sources.knowledgeBase"), + web: t("chat.sources.web"), + document: t("chat.sources.document"), + source: t("chat.sources.source"), + }); useEffect(() => { if (selected) { @@ -379,12 +382,16 @@ const SourceListItem: FC<{ }, [selected]); const handleDocumentDownload = async () => { - if (isDownloading) return; + if (isDownloading || (isAidp && !aidpLocator)) return; setIsDownloading(true); setDownloadError(null); try { const filename = item.filename || item.title || "download"; + if (isAidp && aidpLocator) { + await downloadAidpCitation(aidpLocator, filename); + return; + } if (item.downloadUrl) { const link = document.createElement("a"); link.href = item.downloadUrl; @@ -408,7 +415,8 @@ const SourceListItem: FC<{ throw new Error("Cannot determine the file location."); } await storageService.downloadFileWithAuth(objectName, filename); - } catch { + } catch (error) { + log.error("Failed to download citation file", { searchType: item.searchType, error }); setDownloadError(t("chat.sources.downloadError")); } finally { setIsDownloading(false); @@ -441,7 +449,7 @@ const SourceListItem: FC<{ : [], [citationContext, retrievalHighlightTerms, item.text, selected], ); - const previewUrl = getLocalFilePreviewUrl( + const previewUrl = isAidp ? undefined : getLocalFilePreviewUrl( item.url, item.filename || item.title, item.objectName @@ -467,18 +475,17 @@ const SourceListItem: FC<{
+ {item.title || item.filename || t("chat.sources.document")} } item={item} selected={selected} highlightTerms={highlightTerms} - sourceLabel="来源: Nexent" />
- + }
+ + {downloadError && (

{downloadError}

)} @@ -516,7 +525,7 @@ const SourceListItem: FC<{
+ {displayTitle} } @@ -528,10 +537,11 @@ const SourceListItem: FC<{ item={item} selected={selected} highlightTerms={highlightTerms} - sourceLabel={`来源: ${domain}`} />
+ + ); } @@ -543,20 +553,22 @@ const SourceListItem: FC<{ > + {item.title || t("chat.sources.untitled")} } item={item} selected={selected} highlightTerms={highlightTerms} - sourceLabel="来源: Nexent" /> + + ); }; const ImageListItem: FC<{ item: PanelSourceItem }> = ({ item }) => { + const { t } = useTranslation(); const imageUrl = item.url || ""; const [resolvedUrl, setResolvedUrl] = useState(null); const [loadError, setLoadError] = useState(false); @@ -614,8 +626,18 @@ const ImageListItem: FC<{ item: PanelSourceItem }> = ({ item }) => { loading="lazy" preview proxy={!resolvedUrl} - className="aspect-square w-full object-cover" + className="aspect-square w-full object-contain" /> +
+ {(item.filename || (item.title && item.title !== item.url)) &&

{item.filename || item.title}

} + + +
); }; diff --git a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx index 3ed794444a..ffa04fe2d7 100644 --- a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx +++ b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx @@ -1,5 +1,7 @@ "use client"; +import type { SourceAttributes } from "@/types/source"; + import { memo } from "react"; import { useSourcesPanel } from "./sources-panel-context"; import type { PanelSourceItem } from "./sources-panel"; @@ -180,7 +182,7 @@ function ToolFallbackError({ ); } -type ToolSearchSource = { +type ToolSearchSource = SourceAttributes & { url?: string; title?: string; text?: string; @@ -188,6 +190,9 @@ type ToolSearchSource = { filename?: string; sourceFile?: string; objectName?: string; + publishedDate?: string; + retrievalHighlightTerms?: string[]; + downloadUrl?: string; citeIndex?: number; toolSign?: string; isImage?: boolean; @@ -215,8 +220,14 @@ function ToolFallbackSearchContent({ text: item.text, filename: item.filename || item.sourceFile, objectName: item.objectName, + publishedDate: item.publishedDate, + retrievalHighlightTerms: item.retrievalHighlightTerms, + downloadUrl: item.downloadUrl, citeIndex: item.citeIndex ?? index, toolSign: item.toolSign, + searchType: item.searchType, + sourceMetadata: item.sourceMetadata, + sourceReference: item.sourceReference, })); if (regularSources.length === 0) return null; diff --git a/frontend/components/common/sourceMetadata.module.css b/frontend/components/common/sourceMetadata.module.css new file mode 100644 index 0000000000..5af896a686 --- /dev/null +++ b/frontend/components/common/sourceMetadata.module.css @@ -0,0 +1,112 @@ +.root { + position: relative; + min-width: 0; + margin-top: 8px; +} +.trigger { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + text-align: left; +} +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px 5px; + max-height: 44px; + overflow: hidden; +} +.chip { + display: inline-block; + flex-shrink: 0; + max-width: min(178px, 100%); + height: 20px; + padding: 0 8px; + border-radius: 3px; + background: var(--muted); + color: var(--foreground); + font-size: 12px; + font-weight: 400; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.measure { + position: absolute; + top: 0; + left: 0; + display: flex; + width: 100%; + visibility: hidden; + pointer-events: none; +} +.popover { + width: min(420px, calc(100vw - 56px)); + max-height: min(560px, calc(100dvh - 80px)); + overflow-y: auto; +} +.heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; + font-weight: 500; +} +.heading button { + display: flex; + padding: 4px; + border: 0; + background: transparent; + color: var(--muted-foreground); +} +.properties { + display: grid; + gap: 8px; + font-size: 12px; + line-height: 20px; +} +.property { + display: grid; + grid-template-columns: minmax(68px, 120px) minmax(0, 1fr); + gap: 12px; + margin: 0; +} +.property dt { + color: var(--muted-foreground); + overflow-wrap: anywhere; +} +.property dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} +.group summary { + overflow-wrap: anywhere; +} +.children { + margin-top: 8px; + padding-left: 10px; + border-left: 1px solid var(--border); +} +.json { + margin: 0; + font-size: 12px; + line-height: 20px; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} +@media (max-width: 400px) { + .chip { + max-width: min(150px, 100%); + } + .property { + grid-template-columns: 80px minmax(0, 1fr); + gap: 8px; + } +} diff --git a/frontend/components/common/sourceMetadata.tsx b/frontend/components/common/sourceMetadata.tsx new file mode 100644 index 0000000000..9ca9dc153d --- /dev/null +++ b/frontend/components/common/sourceMetadata.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Popover, Tabs } from "antd"; +import { XIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { JsonObject, JsonValue } from "@/types/source"; +import styles from "./sourceMetadata.module.css"; + +interface SourceMetadataProps { + metadata?: JsonObject; +} + +function isStructured(value: JsonValue): value is JsonObject | JsonValue[] { + return value !== null && typeof value === "object"; +} + +function scalarText(value: JsonValue): string { + return value === "" ? '""' : String(value); +} + +function entriesOf(value: JsonObject | JsonValue[]): [string, JsonValue][] { + return Array.isArray(value) + ? value.map((entry, index) => [`[${index}]`, entry]) + : Object.keys(value) + .sort() + .map((key) => [key, value[key]]); +} + +function MetadataProperties({ value }: { value: JsonObject | JsonValue[] }) { + return ( +
+ {entriesOf(value).map(([key, entry]) => + isStructured(entry) ? ( +
+ {key} +
+ {Object.keys(entry).length ? ( + + ) : Array.isArray(entry) ? ( + "[]" + ) : ( + "{}" + )} +
+
+ ) : ( +
+
{key}
+
{scalarText(entry)}
+
+ ) + )} +
+ ); +} + +/** Per-hit raw-key chips; hover, focus and click reveal the complete snapshot. */ +export function SourceMetadata({ metadata }: SourceMetadataProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [pinned, setPinned] = useState(false); + const [visibleCount, setVisibleCount] = useState(0); + const triggerRef = useRef(null); + const measureRef = useRef(null); + const popoverRef = useRef(null); + const close = () => { + if (popoverRef.current?.contains(document.activeElement)) + triggerRef.current?.focus(); + setPinned(false); + setOpen(false); + }; + useEffect(() => { + if (!pinned) return; + const dismiss = (event: PointerEvent) => { + const target = event.target as Node; + if ( + triggerRef.current?.contains(target) || + popoverRef.current?.contains(target) + ) + return; + setPinned(false); + setOpen(false); + }; + document.addEventListener("pointerdown", dismiss); + return () => document.removeEventListener("pointerdown", dismiss); + }, [pinned]); + const entries = useMemo( + () => (metadata ? entriesOf(metadata) : []), + [metadata] + ); + const labels = useMemo( + () => + entries.map(([key, value]) => { + if (!isStructured(value)) return `${key}:${scalarText(value)}`; + const count = Object.keys(value).length; + const countLabel = t("chat.sources.attributeCount", { count }); + const summary = Array.isArray(value) + ? count + ? `[${countLabel}]` + : "[]" + : count + ? `{${countLabel}}` + : "{}"; + return `${key}:${summary}`; + }), + [entries, t] + ); + + useLayoutEffect(() => { + const trigger = triggerRef.current; + const measure = measureRef.current; + if (!trigger || !measure) return; + const update = () => { + const width = trigger.clientWidth; + if (!width) return; + const children = Array.from(measure.children) as HTMLElement[]; + const overflow = children[labels.length]; + const widths = children + .slice(0, labels.length) + .map((child) => child.getBoundingClientRect().width); + const fits = (count: number) => { + const sizes = widths.slice(0, count); + if (count < labels.length) { + overflow.textContent = `+${labels.length - count}`; + sizes.push(overflow.getBoundingClientRect().width); + } + let rows = 1, + used = 0; + for (const size of sizes) { + if (used && used + 5 + size > width) { + rows++; + used = 0; + } + used += (used ? 5 : 0) + size; + } + return rows <= 2; + }; + let count = labels.length; + while (count > 0 && !fits(count)) count--; + setVisibleCount(count); + }; + update(); + let active = true; + document.fonts?.ready.then(() => { + if (active) update(); + }); + const observer = new ResizeObserver(update); + observer.observe(trigger); + return () => { + active = false; + observer.disconnect(); + }; + }, [labels]); + + if (!metadata || entries.length === 0) return null; + + return ( +
event.stopPropagation()} + > + { + if (!pinned) setOpen(next); + }} + trigger={["hover", "focus"]} + placement="bottomLeft" + mouseEnterDelay={0.15} + mouseLeaveDelay={0.2} + content={ +
{ + if (event.key === "Escape") close(); + }} + > +
+ {t("chat.sources.attributes")} + +
+ , + }, + { + key: "json", + label: t("chat.sources.rawJson"), + children: ( +
+                      {JSON.stringify(metadata, null, 2)}
+                    
+ ), + }, + ]} + /> +
+ } + > + +
+ +
+ ); +} diff --git a/frontend/ext_components/aidp/shared/services/aidpCitationService.ts b/frontend/ext_components/aidp/shared/services/aidpCitationService.ts new file mode 100644 index 0000000000..d6179559d3 --- /dev/null +++ b/frontend/ext_components/aidp/shared/services/aidpCitationService.ts @@ -0,0 +1,24 @@ +import aidpKnowledgeService from "./aidpKnowledgeService"; +import type { AidpDownloadLocator } from "@/types/source"; + +/** Download an explicitly located original file through the existing AIDP proxy. */ +export async function downloadAidpCitation( + locator: AidpDownloadLocator, + filename: string +): Promise { + const response = await aidpKnowledgeService.downloadDoc( + locator.kds_id, + locator.file_uuid + ); + const blobUrl = URL.createObjectURL(await response.blob()); + try { + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(blobUrl); + } +} diff --git a/frontend/lib/source.ts b/frontend/lib/source.ts new file mode 100644 index 0000000000..31b0624d35 --- /dev/null +++ b/frontend/lib/source.ts @@ -0,0 +1,100 @@ +import type { + AidpDownloadLocator, + JsonObject, + SourceAttributes, +} from "@/types/source"; + +const knowledgeProviders: Record = { + knowledge_base_search: "Nexent", + aidp_search: "AIDP", + dify_search: "Dify", + datamate_search: "DataMate", + ragflow_search: "RAGFlow", + idata_search: "iData", + haotian_search: "Haotian", + ind_aidp_search: "AIDP", +}; + +export function getSourceProvider( + searchType?: string, + toolSign?: string +): string | undefined { + if (!searchType && ["j", "l"].includes(toolSign?.toLowerCase() ?? "")) + return "AIDP"; + return searchType && Object.hasOwn(knowledgeProviders, searchType) + ? knowledgeProviders[searchType] + : undefined; +} + +export function getSourceLabel( + source: SourceAttributes & { sourceType?: string; url?: string }, + labels: { + knowledgeBase: string; + web: string; + document: string; + source: string; + } +): string { + const provider = getSourceProvider(source.searchType, source.toolSign); + if (provider) return `${labels.knowledgeBase} · ${provider}`; + if (source.sourceType === "document" || source.sourceType === "file") + return labels.document; + if (source.url) { + try { + return `${labels.web} · ${new URL(source.url).hostname.replace(/^www\./, "")}`; + } catch { + return labels.source; + } + } + return labels.source; +} + +export function getSourceAttributes( + searchType: unknown, + scoreDetails: unknown +): SourceAttributes { + const details = + scoreDetails && typeof scoreDetails === "object" + ? (scoreDetails as Record) + : {}; + const { metadata, ...sourceReference } = details; + return { + searchType: typeof searchType === "string" ? searchType : undefined, + sourceMetadata: + metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as JsonObject) + : undefined, + sourceReference, + }; +} + +export function getAidpDownloadLocator( + source: SourceAttributes +): AidpDownloadLocator | undefined { + if ( + source.searchType !== "aidp_search" && + !(!source.searchType && source.toolSign?.toLowerCase() === "j") + ) + return undefined; + const locator = source.sourceReference?.download_locator; + if (!locator || typeof locator !== "object") return undefined; + const value = locator as Record; + if ( + value.provider !== "aidp" || + typeof value.kds_id !== "string" || + !value.kds_id.trim() + ) + return undefined; + if ( + typeof value.file_uuid !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value.file_uuid + ) + ) + return undefined; + return value as unknown as AidpDownloadLocator; +} + +export function isAidpSource(source: SourceAttributes): boolean { + return getSourceProvider(source.searchType, source.toolSign) === "AIDP"; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 11a6761be5..e1c884f216 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -281,6 +281,15 @@ "chat.sources.matchedChunk": "Matched index chunk", "chat.sources.untitled": "Untitled source", "chat.sources.downloadError": "Download failed. Please try again.", + "chat.sources.referenceTitle": "References ({{count}})", + "chat.sources.attributes": "Source attributes", + "chat.sources.properties": "Properties", + "chat.sources.rawJson": "Raw JSON", + "chat.sources.showAttributes": "View complete source attributes", + "chat.sources.closeAttributes": "Close source attributes", + "chat.sources.attributeCount": "{{count}} items", + "chat.sources.attributeCount_one": "{{count}} item", + "chat.sources.attributeCount_other": "{{count}} items", "chat.attachments.preview": "Preview {{name}}", "chat.attachments.remove": "Remove {{name}}", "chat.attachments.download": "Download", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9b709dbfbe..f4d6fe8772 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -281,6 +281,13 @@ "chat.sources.matchedChunk": "命中的索引片段", "chat.sources.untitled": "未命名来源", "chat.sources.downloadError": "下载失败,请重试。", + "chat.sources.referenceTitle": "参考来源({{count}})", + "chat.sources.attributes": "来源属性", + "chat.sources.properties": "属性", + "chat.sources.rawJson": "原始 JSON", + "chat.sources.showAttributes": "查看完整来源属性", + "chat.sources.closeAttributes": "关闭来源属性", + "chat.sources.attributeCount": "{{count}} 项", "chat.attachments.preview": "预览 {{name}}", "chat.attachments.remove": "移除 {{name}}", "chat.attachments.download": "下载", diff --git a/frontend/tests/workbench/StreamCancellation.test.ts b/frontend/tests/workbench/StreamCancellation.test.ts index 3357649be3..40ebf3cda0 100644 --- a/frontend/tests/workbench/StreamCancellation.test.ts +++ b/frontend/tests/workbench/StreamCancellation.test.ts @@ -5,7 +5,12 @@ import { remoteChatModelAdapter, } from "@/app/newchat/adapter/remote-chat-model-adapter"; import { ReadableStream } from "node:stream/web"; -import { restoreHistoricalPlan } from "@/app/newchat/adapter/conversation-thread-list-adapter"; +import { + RemoteConversationHistoryAdapter, + restoreHistoricalPlan, +} from "@/app/newchat/adapter/conversation-thread-list-adapter"; +import type { ApiConversationDetail } from "@/types/conversation"; +import type { SourceAttributes } from "@/types/source"; const service = vi.hoisted(() => ({ runAgent: vi.fn(), stop: vi.fn() })); vi.mock("@/services/conversationService", () => ({ @@ -239,3 +244,229 @@ it("passes Start Chat thinking settings to the normal agent run", async () => { generation_config: { deep_thinking: true, thinking_effort: "medium" }, }); }); + +const sourceHits = [ + { + title: "same.pdf", + filename: "same.pdf", + text: "First hit", + url: "", + source_type: "file", + search_type: "aidp_search", + tool_sign: "j", + cite_index: 1, + published_date: "2026-10-14", + score_details: { + metadata: { + count: 0, + enabled: false, + nested: { values: [0, false, "中文"] }, + }, + chunk_id: "first-hit", + }, + }, + { + title: "same.pdf", + filename: "same.pdf", + text: "Second hit", + url: "", + source_type: "file", + search_type: "aidp_search", + tool_sign: "j", + cite_index: 2, + published_date: "2026-10-13", + score_details: { + metadata: { count: 1, source: "business source" }, + chunk_id: "second-hit", + }, + }, +]; +type SourceTestPart = SourceAttributes & { + type: string; + url?: string; + imageKey?: string; + publishedDate?: string; + searchContent?: SourceAttributes[]; +}; + +it.each([true, false])( + "retains per-hit source snapshots in streamed tool and answer projections (terminated=%s)", + async (terminated) => { + const events = [ + { type: "tool", content: "aidp_search", tool_call_id: "call-search" }, + { + type: "search_content", + content: JSON.stringify(sourceHits), + tool_call_id: "call-search", + }, + ]; + service.runAgent.mockImplementationOnce(async () => + new ReadableStream({ + start(controller) { + const data = + events + .map((event) => `data: ${JSON.stringify(event)}`) + .join("\n\n") + (terminated ? "\n\n" : ""); + controller.enqueue(new TextEncoder().encode(data)); + controller.close(); + }, + }).getReader() + ); + const output = await consume( + options(new AbortController(), { threadId: "12", agentId: 7 }) + ); + const parts = output.at(-1)?.content as unknown as SourceTestPart[]; + const sources = parts.filter((part) => part.type === "source"); + expect(sources.map((part) => part.sourceMetadata)).toEqual( + sourceHits.map((hit) => hit.score_details.metadata) + ); + expect(sources.map((part) => part.sourceReference?.chunk_id)).toEqual([ + "first-hit", + "second-hit", + ]); + expect(sources.every((part) => part.searchType === "aidp_search")).toBe( + true + ); + expect(sources.map((part) => part.publishedDate)).toEqual([ + "2026-10-14", + "2026-10-13", + ]); + const tool = parts.find((part) => part.type === "tool-call")!; + expect(tool.searchContent?.map((part) => part.sourceMetadata)).toEqual( + sourceHits.map((hit) => hit.score_details.metadata) + ); + } +); + +it("restores per-hit snapshots into historical tool content and final source parts", async () => { + const detail = { + conversation_id: "42", + create_time: 0, + message: [ + { + role: "assistant", + message_id: 5, + message: [ + { type: "tool", content: "aidp_search", tool_call_id: "call-search" }, + { + type: "search_content_placeholder", + content: JSON.stringify({ unit_id: 77 }), + tool_call_id: "call-search", + }, + { type: "final_answer", content: "Answer [[j1]][[j2]]" }, + ], + search: sourceHits, + searchByUnitId: { "77": sourceHits }, + }, + ], + } as ApiConversationDetail; + const adapter = new RemoteConversationHistoryAdapter( + () => "42", + async () => ({ remoteId: "42", externalId: "42" }), + async () => detail + ); + const history = await adapter.load(); + const parts = history.messages[0].message + .content as unknown as SourceTestPart[]; + const sources = parts.filter((part) => part.type === "source"); + expect(sources.map((part) => part.sourceMetadata)).toEqual( + sourceHits.map((hit) => hit.score_details.metadata) + ); + expect(sources.map((part) => part.sourceReference?.chunk_id)).toEqual([ + "first-hit", + "second-hit", + ]); + expect(sources.every((part) => part.searchType === "aidp_search")).toBe(true); + expect(sources.map((part) => part.publishedDate)).toEqual([ + "2026-10-14", + "2026-10-13", + ]); + expect( + parts + .find((part) => part.type === "tool-call") + ?.searchContent?.map((part) => part.sourceMetadata) + ).toEqual(sourceHits.map((hit) => hit.score_details.metadata)); +}); + +it("keeps image metadata attached to its URL when picture events arrive out of order", async () => { + const images = sourceHits.map((hit, index) => ({ + ...hit, + url: `https://aidp.example/images/${index}.png`, + image_key: `j${index + 1}`, + score_details: { ...hit.score_details, chunk_type: "image" }, + })); + const events = [ + { type: "search_content", content: JSON.stringify(images) }, + { + type: "picture_web", + content: JSON.stringify({ + images_url: images.map((hit) => hit.url).reverse(), + }), + }, + ]; + service.runAgent.mockImplementationOnce(async () => + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + ) + ); + controller.close(); + }, + }).getReader() + ); + const output = await consume( + options(new AbortController(), { threadId: "12", agentId: 7 }) + ); + const parts = output.at(-1)?.content as unknown as SourceTestPart[]; + const sources = parts.filter((part) => part.type === "source"); + expect( + sources.map((part) => [part.url, part.imageKey, part.sourceMetadata]) + ).toEqual( + [...images] + .reverse() + .map((hit) => [hit.url, hit.image_key, hit.score_details.metadata]) + ); +}); + +it("restores separate image hits with the same URL and different metadata", async () => { + const imageUrl = "https://aidp.example/images/shared.png"; + const images = sourceHits.map((hit, index) => ({ + ...hit, + url: imageUrl, + image_key: `j${index + 1}`, + score_details: { ...hit.score_details, chunk_type: "image" }, + })); + const detail = { + conversation_id: "42", + create_time: 0, + message: [ + { + role: "assistant", + message_id: 5, + message: [ + { + type: "picture_web", + content: JSON.stringify({ images_url: [imageUrl] }), + }, + ], + search: images, + picture: [imageUrl], + }, + ], + } as ApiConversationDetail; + const adapter = new RemoteConversationHistoryAdapter( + () => "42", + async () => ({ remoteId: "42", externalId: "42" }), + async () => detail + ); + const history = await adapter.load(); + const parts = history.messages[0].message + .content as unknown as SourceTestPart[]; + expect( + parts + .filter((part) => part.type === "source") + .map((part) => [part.imageKey, part.sourceMetadata]) + ).toEqual(images.map((hit) => [hit.image_key, hit.score_details.metadata])); +}); diff --git a/frontend/types/source.ts b/frontend/types/source.ts new file mode 100644 index 0000000000..9a67ce2ec7 --- /dev/null +++ b/frontend/types/source.ts @@ -0,0 +1,19 @@ +export type JsonValue = + string | number | boolean | null | JsonValue[] | JsonObject; + +export interface JsonObject { + [key: string]: JsonValue; +} + +export interface AidpDownloadLocator { + provider: "aidp"; + kds_id: string; + file_uuid: string; +} + +export interface SourceAttributes { + searchType?: string; + toolSign?: string; + sourceMetadata?: JsonObject; + sourceReference?: Record; +} diff --git a/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py b/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py index a00aed56bb..93caa6d26d 100644 --- a/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py +++ b/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py @@ -280,7 +280,7 @@ def _emit_running_prompt(self, query: str) -> None: "", ProcessType.CARD, json.dumps(card_content, ensure_ascii=False) ) - def _build_chunk_message(self, chunk: Dict[str, Any], idx: int): + def _build_chunk_message(self, chunk: Dict[str, Any], idx: int, citation_start: Optional[int] = None): """Build a SearchResultTextMessage for a single record chunk.""" chunk_type = str(chunk.get("chunk_type", "text") or "text") title = str(chunk.get("title") or "") @@ -304,8 +304,9 @@ def _build_chunk_message(self, chunk: Dict[str, Any], idx: int): "pages": pages, "file_url": file_url, "metadata": metadata, + "retrieval_source": chunk.get("retrieval_source"), }, - cite_index=self.record_ops + idx, + cite_index=(self.record_ops if citation_start is None else citation_start) + idx, search_type=self.name, tool_sign=self.tool_sign, ) @@ -317,8 +318,13 @@ def _process_records(self, records: List[Dict[str, Any]]): search_results_return: List[Dict[str, Any]] = [] images_url: List[str] = [] - for idx, chunk in enumerate(records[: self.top_k]): - msg = self._build_chunk_message(chunk, idx) + chunks = records[: self.top_k] + citation_start = ( + self.observer.reserve_citations(self.tool_sign, len(chunks)) + if self.observer else self.record_ops + ) + for idx, chunk in enumerate(chunks): + msg = self._build_chunk_message(chunk, idx, citation_start) ui_result = msg.to_dict() ui_result["text"] = _HTML_IMAGE_TAG_PATTERN.sub("", ui_result["text"]) search_results_json.append(ui_result) @@ -335,6 +341,7 @@ def _process_records(self, records: List[Dict[str, Any]]): # value because they aren't rendered as tags. if chunk_type == "image" and file_url: full_url = self._build_image_url(file_url) + ui_result["url"] = full_url # Do NOT expose the image URL to the LLM: the URL is an # AIDP endpoint that requires a Bearer token, so if the # model embeds it in markdown the browser will GET it diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py index 9dabda4827..623b83536d 100644 --- a/sdk/nexent/core/utils/observer.py +++ b/sdk/nexent/core/utils/observer.py @@ -138,6 +138,7 @@ def __init__(self, lang="zh", enable_nl2a_wrapper=False): # Unified output queue consumed by the agent streaming bridge. self.message_query = [] self._message_query_lock = threading.Lock() + self._citation_indexes: dict[str, int] = {} # Control output language self.lang = lang @@ -185,6 +186,13 @@ def __init__(self, lang="zh", enable_nl2a_wrapper=False): ) self._step_outputs: dict[str | None, dict[str, Any]] = {} + def reserve_citations(self, tool_sign: str, count: int) -> int: + """Reserve unique citation indexes across tools sharing this run observer.""" + with self._message_query_lock: + start = self._citation_indexes.get(tool_sign, 1) + self._citation_indexes[tool_sign] = start + count + return start + def _step_output(self) -> dict[str, Any]: active = self._active_subagent() key = active[0] if active else None diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml index d8c1fe7c94..e3c40d6825 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml @@ -6,6 +6,8 @@ case: feature_id: F-065 business_rule_ids: - F-065-RULE-01 + - F-065-RULE-02 + - F-065-RULE-03 title: 引用角标组合key匹配、旧角标兼容、句级高亮与来源字段透传|核心流程 type: FE-COMP priority: P1 @@ -43,13 +45,45 @@ case: - order: 7 action: 对匹配不到来源的角标断言 loading 状态正确、不抛出未处理异常。 expected: retrievalHighlightTerms/publishedDate 透传无丢失;匹配不到来源时 loading 正确且无未处理异常。 + - order: 8 + action: 注入同名 AIDP 多切片且 metadata 含零、false、数组、嵌套对象的来源,点击各自引用并检查面板和属性悬浮内容;无 metadata 时检查没有空区域。 + expected: 每条命中的原始 metadata、searchType 与技术引用完整透传;提供方显示 AIDP 而业务 source 原值保留;属性可通过悬浮、聚焦和点击访问,操作不触发文件预览。 + - order: 9 + action: 对无定位的 AIDP 来源注入文件名、切片或图片 URL,检查无下载按钮和无本地存储调用;对有效内部定位检查复用 AIDP 下载服务,对本地附件检查原下载行为。 + expected: AIDP 无有效原文件定位时无下载动作;有效内部定位走已有 AIDP 服务,本地附件仍走已有存储下载。 expected_results: - 组合 key 精确匹配各自来源,a1 与 b1 互不混淆并返回对应 SearchSource。 - 纯数字旧角标回退匹配到相等 citeIndex 来源,历史会话保持可读。 - 开启句级高亮时 citationContext 返回角标前一句完整 scope 文本,且不跨段落/跨表格单元扩展。 - 高亮开启与关闭情况下点击角标均能用 selectedCitationKey 选中并滚动到来源。 - retrievalHighlightTerms/publishedDate 透传无丢失;匹配不到来源时 loading 正确且无未处理异常。 - forbidden_side_effects: [] + - metadata 和技术引用按命中完整透传,原始 key/value 可访问,提供方与业务 source 分开且空 metadata 无区域。 + - AIDP 下载遵循有效定位和既有服务契约;本地附件回归通过。 + forbidden_side_effects: + - 无定位 AIDP 不调用本地存储或按文件名、切片 ID、图片地址猜原文件;查看属性不触发预览或下载。 + acceptance: + - id: AC-01 + description: Distinct composite keys and numeric legacy compatibility remain correct. + steps: [1, 2, 3] + expected_results: [1, 2] + - id: AC-02 + description: Sentence scope, selection and scrolling remain correct. + steps: [4, 5] + expected_results: [3, 4] + - id: AC-03 + description: Existing source fields and unmatched markers remain readable. + steps: [6, 7] + expected_results: [5] + - id: AC-04 + description: Per-hit metadata, provider and technical reference reach the real panel and selectable attributes without preview side effects. + steps: [8] + expected_results: [6] + forbidden_side_effects: [1] + - id: AC-05 + description: AIDP downloads require explicit original identity and never fall back to local attachments; local downloads remain available. + steps: [9] + expected_results: [7] + forbidden_side_effects: [1] source: legacy_workbook_sha256: aa4b32414d30f12b3b64e14236268145e556bd7cfc5d3eb8df80731608ebc022 legacy_sheet: 02_测试用例基线 diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml index 17806a80de..1243809f33 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml @@ -5,3 +5,23 @@ implementations: file: test.test.tsx selector: UT-FE-AUTO-82166F602745F6D7 notes: V5 source binding migrated; runtime parity is not yet certified. +acceptance_bindings: + AC-01: + - getCitationKey combined citation key generation > UT-FE-AUTO-82166F602745F6D7 builds distinct combined keys from toolSign + citeIndex + - getCitationKey combined citation key generation > falls back to a numeric key when toolSign is missing + - citation marker resolution via findCiteSource > matches a1 and b1 to their own sources without confusion + - citation marker resolution via findCiteSource > resolves a numeric-only legacy marker to the source with no toolSign + AC-02: + - citation click behaviour > computes the preceding sentence scope as citationContext when highlight is on + - citation click behaviour > stops sentence scope at a newline separator + - citation click behaviour > stops sentence scope at a table-cell separator + - SourcesPanel selection and passthrough > selects and scrolls to the source matching selectedCitationKey and renders publishedDate + AC-03: + - citation click behaviour > opens the panel with selectedCitationKey and passes through source fields when highlight is off + - citation marker resolution via findCiteSource > shows loading for an unmatched citekey without throwing + AC-04: + - per-hit attributes and safe citation downloads > keeps same-name hit snapshots distinct from citation click through the real panel + - per-hit attributes and safe citation downloads > omits empty attributes and supports focus access to a complete raw snapshot + AC-05: + - per-hit attributes and safe citation downloads > never enables AIDP downloads from names, business metadata, chunk IDs or image URLs + - per-hit attributes and safe citation downloads > uses the existing AIDP proxy for explicit locators and preserves local downloads diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx index b660c667f8..30706318c8 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, within, waitFor, act } from '@testing-library/react'; import { getCitationKey, getCitationLabel, @@ -12,12 +12,16 @@ const h = vi.hoisted(() => ({ aui: { message: { id: 'msg-1', content: [] as any[] } }, open: vi.fn(), citeMarkerProps: [] as any[], + storageDownload: vi.fn(), + aidpDownload: vi.fn(), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }), })); +vi.mock('@/lib/logger', () => ({ default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } })); + vi.mock('@/const/citation', () => ({ get ENABLE_CITATION_CLICK_HIGHLIGHT() { return h.citation.enabled; @@ -34,7 +38,11 @@ vi.mock('@/services/storageService', () => ({ extractObjectNameFromUrl: () => null, fetchImageBlob: () => Promise.reject(new Error('not-implemented')), getLocalFilePreviewUrl: () => undefined, - storageService: { downloadFileWithAuth: () => Promise.resolve() }, + storageService: { downloadFileWithAuth: h.storageDownload }, +})); + +vi.mock('@/ext_components/aidp/shared/services/aidpKnowledgeService', () => ({ + default: { downloadDoc: h.aidpDownload }, })); vi.mock('@assistant-ui/react-markdown', () => ({ @@ -103,11 +111,14 @@ const Cite = defaultComponents.cite as unknown as (props: { }) => any; beforeEach(() => { + HTMLElement.prototype.scrollIntoView = vi.fn(); h.citation.enabled = false; h.aui.message.id = 'msg-1'; h.aui.message.content = []; h.open.mockClear(); h.citeMarkerProps.length = 0; + h.storageDownload.mockReset().mockResolvedValue(undefined); + h.aidpDownload.mockReset().mockResolvedValue({ blob: async () => new Blob(['mock sample']) }); }); describe('getCitationKey combined citation key generation', () => { @@ -207,6 +218,9 @@ describe('citation click behaviour', () => { url: 'https://a.example.com', publishedDate: '2026-01-01', retrievalHighlightTerms: ['alpha', 'beta'], + searchType: 'aidp_search', + sourceMetadata: { count: 0, enabled: false, custom: { values: [0, false] } }, + sourceReference: { chunk_id: 'chunk-a' }, }, ]; render(); @@ -219,6 +233,9 @@ describe('citation click behaviour', () => { expect(payload.citationContext).toBeUndefined(); expect(payload.sources[0].retrievalHighlightTerms).toEqual(['alpha', 'beta']); expect(payload.sources[0].publishedDate).toBe('2026-01-01'); + expect(payload.sources[0].searchType).toBe('aidp_search'); + expect(payload.sources[0].sourceMetadata).toEqual({ count: 0, enabled: false, custom: { values: [0, false] } }); + expect(payload.sources[0].sourceReference).toEqual({ chunk_id: 'chunk-a' }); }); it('computes the preceding sentence scope as citationContext when highlight is on', () => { @@ -299,3 +316,101 @@ describe('SourcesPanel selection and passthrough', () => { } }); }); + +describe('per-hit attributes and safe citation downloads', () => { + const first = { count: 0, enabled: false, source: 'business source', tags: [0, false, '中文'], standards: { code: { level: 'E' } } }; + const second = { count: 1, enabled: true }; + const sources = [ + { sourceType: 'document' as const, searchType: 'aidp_search', filename: 'same.pdf', title: 'same.pdf', citeIndex: 1, toolSign: 'j', sourceMetadata: first, sourceReference: { chunk_id: 'chunk-first' } }, + { sourceType: 'document' as const, searchType: 'aidp_search', filename: 'same.pdf', title: 'same.pdf', citeIndex: 2, toolSign: 'j', sourceMetadata: second, sourceReference: { chunk_id: 'chunk-second' } }, + ]; + + it('keeps same-name hit snapshots distinct from citation click through the real panel', async () => { + h.aui.message.content = [{ type: 'text', text: 'Answer [[j1]][[j2]]' }, ...sources.map(source => ({ ...source, type: 'source' }))]; + const marker = render(); + h.citeMarkerProps[0].onClick(null); + const payload = h.open.mock.calls[0][0]; + expect(payload.selectedCitationKey).toBe('j2'); + expect(payload.sources.map((source: any) => source.sourceMetadata)).toEqual([first, second]); + expect(payload.sources.map((source: any) => source.sourceReference.chunk_id)).toEqual(['chunk-first', 'chunk-second']); + marker.unmount(); + const preview = vi.spyOn(window, 'open').mockImplementation(() => null); + const panel = render( {}} />); + const triggers = within(panel.container).getAllByRole('button', { name: 'chat.sources.showAttributes' }); + fireEvent.mouseEnter(triggers[0]); + const dialog = await screen.findByRole('dialog', { name: 'chat.sources.attributes' }); + fireEvent.click(triggers[0]); + fireEvent.mouseLeave(triggers[0]); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 250)); }); + expect(triggers[0].getAttribute('aria-expanded')).toBe('true'); + expect(within(dialog).getAllByText('false')).toHaveLength(2); + expect(within(dialog).getByText('business source')).toBeTruthy(); + fireEvent.click(within(dialog).getByText('standards')); + fireEvent.click(within(dialog).getByText('code')); + expect(within(dialog).getByText('E')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('tab', { name: 'chat.sources.rawJson' })); + expect(JSON.parse(dialog.querySelector('pre')!.textContent!)).toEqual(first); + expect(within(panel.container).getAllByText('chat.sources.knowledgeBase · AIDP')).toHaveLength(2); + expect(preview).not.toHaveBeenCalled(); + expect(h.storageDownload).not.toHaveBeenCalled(); + expect(h.aidpDownload).not.toHaveBeenCalled(); + preview.mockRestore(); + }); + + it('omits empty attributes and supports focus access to a complete raw snapshot', async () => { + const panel = render( {}} />); + expect(panel.container.querySelectorAll('[data-source-metadata]')).toHaveLength(1); + const trigger = within(panel.container).getByRole('button', { name: 'chat.sources.showAttributes' }); + fireEvent.focus(trigger); + const dialog = await screen.findByRole('dialog', { name: 'chat.sources.attributes' }); + fireEvent.click(within(dialog).getByRole('tab', { name: 'chat.sources.rawJson' })); + expect(JSON.parse(dialog.querySelector('pre')!.textContent!)).toEqual(first); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('false')); + }); + + it('never enables AIDP downloads from names, business metadata, chunk IDs or image URLs', () => { + const uuid = '12345678-1234-1234-1234-123456789abc'; + const panel = render( {}} />); + expect(within(panel.container).queryByRole('button', { name: 'chat.sources.download' })).toBeNull(); + expect(h.storageDownload).not.toHaveBeenCalled(); + expect(h.aidpDownload).not.toHaveBeenCalled(); + }); + + it('uses the existing AIDP proxy for explicit locators and preserves local downloads', async () => { + const uuid = '12345678-1234-1234-1234-123456789abc'; + const create = URL.createObjectURL, revoke = URL.revokeObjectURL; + URL.createObjectURL = vi.fn(() => 'blob:unit-download'); + URL.revokeObjectURL = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + try { + const panel = render( {}} />); + const buttons = within(panel.container).getAllByRole('button', { name: 'chat.sources.download' }); + fireEvent.click(buttons[0]); + await waitFor(() => expect(h.aidpDownload).toHaveBeenCalledWith('kb-explicit', uuid)); + expect(h.storageDownload).not.toHaveBeenCalled(); + await waitFor(() => expect(click).toHaveBeenCalledOnce()); + fireEvent.click(buttons[1]); + await waitFor(() => expect(h.storageDownload).toHaveBeenCalledWith('attachments/local.pdf', 'local.pdf')); + expect(h.aidpDownload).toHaveBeenCalledOnce(); + h.aidpDownload.mockRejectedValueOnce(new Error('AIDP file unavailable')); + fireEvent.click(buttons[0]); + await screen.findByText('chat.sources.downloadError'); + expect(h.storageDownload).toHaveBeenCalledOnce(); + fireEvent.click(buttons[0]); + await waitFor(() => expect(h.aidpDownload).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(within(panel.container).queryByText('chat.sources.downloadError')).toBeNull()); + } finally { click.mockRestore(); URL.createObjectURL = create; URL.revokeObjectURL = revoke; } + }); +}); diff --git a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml new file mode 100644 index 0000000000..6d40a0deae --- /dev/null +++ b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml @@ -0,0 +1,32 @@ +schema_version: '1.0' +change: + change_id: CHAT-AIDP-SOURCES-20261014 + change_type: requirement + requirement_id: CHAT-AIDP-SOURCES-20261014 + title: Persist per-hit citation metadata and prevent AIDP local download fallback + description: Show two-row raw-key source attributes with hover details, restore metadata and provider/reference snapshots in history, and require explicit original-file identity for AIDP downloads. + affected_features: + existing: [F-065] + added: [] + modified: [F-065] + retired: [] + affected_cases: + existing: [UT-FE-AUTO-82166F602745F6D7] + added: [] + modified: [UT-FE-AUTO-82166F602745F6D7] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Preserve distinct per-hit business JSON and technical references in stream buffering, batch and legacy source writes, and history/sources restoration, including zero, false and nested values. + - Keep old records without snapshots readable and preserve existing transactional, authorization and error behavior. + - Preserve image hit identity when picture events arrive out of order or multiple hits share one URL. Retain SDK retrieval identity without treating chunk IDs, filenames, metadata or image URLs as original-file locators. + reason: Existing source SDK/database/history UT own these isolated serialization and write/error contracts. Strengthen their payload assertions; the existing frontend D1 adds actual citation-to-panel collaboration and forbidden download side effects, while browser/runtime evidence is recorded separately. + test_selectors: + - test/ext_components/aidp/test_aidp_search_tool.py + - test/ext_components/aidp/test_aidp_citation_identity.py + - test/backend/database/test_conversation_db.py + - test/backend/services/test_conversation_management_service.py + - test/backend/services/test_agent_run_final_answers.py + - frontend/tests/workbench/StreamCancellation.test.ts + notes: Real AIDP original UUID and byte verification remain unverified; the documented FusionSearch contract supplies no original download locator. Preserve existing blocked higher-layer Cases, pipeline/history logic, and local attachment behavior. Syncthing documents are read-only; the approved SPEC and local verification record are maintained outside Git. diff --git a/test-e2e/features/F-065/feature.yaml b/test-e2e/features/F-065/feature.yaml index f87de61518..46ca2dd9f7 100644 --- a/test-e2e/features/F-065/feature.yaml +++ b/test-e2e/features/F-065/feature.yaml @@ -11,6 +11,10 @@ feature: business_rules: - rule_id: F-065-RULE-01 description: 消息、工具卡片、Plan/ReAct/并行卡片、停止按钮、错误恢复;执行代码块(parse)、可恢复告警(warning)、历史摘要(history_summary)、引用角标(citation key)与句级高亮、模型可用性过滤、线程Agent绑定 + - rule_id: F-065-RULE-02 + description: 引用来源按每条命中保留业务 metadata、技术引用和检索提供方;实时与历史一致,原始字段两行标签及悬浮详情可访问,业务 source 不覆盖提供方。 + - rule_id: F-065-RULE-03 + description: AIDP 原文下载必须具备明确有效的原文件定位并复用已有代理;缺定位不提供下载动作、不按切片或文件名走本地存储,本地附件功能保留。 required_test_stages: - D1 source: diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index 16d59df9ef..584739f4cf 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -1253,6 +1253,8 @@ def test_persist_assistant_run_batch_uses_one_transaction(monkeypatch): "source_title": "Result", "source_location": "https://example.com", "source_content": "content", + "source_metadata": {"count": 0, "enabled": False, "nested": {"tags": [0, False]}}, + "source_reference": {"chunk_id": "chunk-first", "retrieval_source": "vector"}, "cite_index": 1, "search_type": "web_search", "tool_sign": "web", @@ -1269,6 +1271,10 @@ def test_persist_assistant_run_batch_uses_one_transaction(monkeypatch): ctx.__enter__.assert_called_once() ctx.__exit__.assert_called_once() assert session.execute.call_count == 5 + source_row = session.execute.call_args_list[2].args[1][0] + assert source_row["source_metadata"] == {"count": 0, "enabled": False, "nested": {"tags": [0, False]}} + assert source_row["source_reference"] == {"chunk_id": "chunk-first", "retrieval_source": "vector"} + assert source_row["unit_id"] == 101 assert parent.message_content == "done" assert parent.status == "completed" assert json.loads(parent.minio_files) == [ @@ -2783,12 +2789,16 @@ def test_create_source_search_with_unit_id(monkeypatch, fresh_insert_mock): "search_type": "search", "tool_sign": "web_search", "unit_id": 42, + "source_metadata": {"count": 0, "enabled": False, "list": ["a", "b"]}, + "source_reference": {"chunk_id": "chunk-42"}, }, user_id="actor", ) assert search_id == 88 assert fresh_insert_mock["unit_id"] == 42 + assert fresh_insert_mock["source_metadata"] == {"count": 0, "enabled": False, "list": ["a", "b"]} + assert fresh_insert_mock["source_reference"] == {"chunk_id": "chunk-42"} def test_create_source_search_with_published_date(monkeypatch, fresh_insert_mock): diff --git a/test/backend/services/test_agent_run_final_answers.py b/test/backend/services/test_agent_run_final_answers.py index 644f0bc042..0e3b8641fd 100644 --- a/test/backend/services/test_agent_run_final_answers.py +++ b/test/backend/services/test_agent_run_final_answers.py @@ -81,3 +81,27 @@ async def test_child_empty_completion_does_not_replace_or_repeat_parent_result(s assert [(item.get("invocation_id"), item["content"]) for item in batch["message_units"]] == [ ("child", "Child"), (None, "Parent")] assert batch["message_content"] == "Parent" + + +@pytest.mark.asyncio +async def test_search_hit_snapshots_survive_stream_buffering_without_filename_grouping(stream_capture): + metadata = [{"count": 0, "enabled": False, "tags": [0, False]}, {"standards": {"code": {"level": "E"}}}] + hits = [ + {"title": "same.pdf", "filename": "same.pdf", "text": f"chunk {index}", "url": "", + "source_type": "file", "search_type": "aidp_search", "tool_sign": "j", "cite_index": index + 1, + "score_details": {"metadata": value, "chunk_id": f"chunk-{index}", "retrieval_source": "vector"}} + for index, value in enumerate(metadata) + ] + published, batch = await stream_capture([ + {"type": "search_content", "content": json.dumps(hits), "tool_call_id": "call-search", "invocation_id": "child"}, + {"type": "final_answer", "content": "Answer [[j1]][[j2]]"}, + ]) + assert json.loads(published[0]["content"]) == hits + records = batch["search_records"] + assert len(records) == 2 + assert [record["source_metadata"] for record in records] == metadata + assert [record["source_reference"]["chunk_id"] for record in records] == ["chunk-0", "chunk-1"] + assert all("metadata" not in record["source_reference"] for record in records) + unit = batch["message_units"][0] + assert unit["tool_call_id"] == "call-search" and unit["invocation_id"] == "child" + assert all(record["unit_index"] == unit["unit_index"] for record in records) diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index baf1fa85f8..18f745e71b 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -1050,7 +1050,9 @@ def test_get_sources_service_by_message(self, mock_get_images, mock_get_searches "score_semantic": 0.8, "cite_index": 1, "search_type": "web_search", - "tool_sign": "web_search" + "tool_sign": "web_search", + "source_metadata": {"count": 0, "enabled": False, "nested": {"values": [0, False]}}, + "source_reference": {"chunk_id": "chunk-source", "retrieval_source": "vector"}, } ] mock_get_searches.return_value = mock_searches @@ -1074,6 +1076,10 @@ def test_get_sources_service_by_message(self, mock_get_images, mock_get_searches self.assertEqual(search["published_date"], "2023-01-15") self.assertEqual(search["score"], 0.95) self.assertEqual(search["score_details"]["accuracy"], 0.9) + self.assertEqual(search["score_details"]["metadata"], {"count": 0, "enabled": False, "nested": {"values": [0, False]}}) + self.assertEqual(search["score_details"]["chunk_id"], "chunk-source") + self.assertEqual(search["cite_index"], 1) + self.assertEqual(search["search_type"], "web_search") # Check images self.assertEqual(len(result["data"]["images"]), 1) self.assertEqual(result["data"]["images"][0], @@ -1728,7 +1734,15 @@ def test_with_search_records(self, mock_get): "source_type": "web", "source_location": "http://x.com", "published_date": "2023-01-01", "score_overall": 0.9, "score_accuracy": 0.8, "score_semantic": 0.7, "retrieval_highlight_terms": ["Content"], - "cite_index": 1, "search_type": "web", "tool_sign": "search"} + "cite_index": 1, "search_type": "aidp_search", "tool_sign": "j", + "source_metadata": {"section": 0, "enabled": False, "tags": [0, False]}, + "source_reference": {"chunk_id": "chunk-first"}}, + {"unit_id": 10, "message_id": 2, "source_title": "Doc 1", "source_content": "Second content", + "source_type": "file", "source_location": "", "published_date": None, + "score_overall": 0.8, "score_accuracy": None, "score_semantic": None, + "cite_index": 2, "search_type": "aidp_search", "tool_sign": "j", + "source_metadata": {"section": 1, "standards": {"code": {"level": "E"}}}, + "source_reference": {"chunk_id": "chunk-second"}}, ], "image_records": [] } @@ -1738,8 +1752,11 @@ def test_with_search_records(self, mock_get): # Check search is grouped by message msg = result[0]["message"][0] self.assertIn("search", msg) - self.assertEqual(len(msg["search"]), 1) + self.assertEqual(len(msg["search"]), 2) self.assertEqual(msg["search"][0]["title"], "Doc 1") + self.assertEqual(msg["search"][0]["score_details"]["metadata"], {"section": 0, "enabled": False, "tags": [0, False]}) + self.assertEqual(msg["search"][1]["score_details"]["metadata"], {"section": 1, "standards": {"code": {"level": "E"}}}) + self.assertEqual([source["score_details"]["chunk_id"] for source in msg["search"]], ["chunk-first", "chunk-second"]) self.assertEqual( msg["search"][0]["score_details"]["retrieval_highlight_terms"], ["Content"], diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index eb307c1eab..f94c1fa3df 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -1221,11 +1221,22 @@ def knowledge_file_history( "score": 0.96, "title": "AIDP产品介绍.pdf", "text": "AIDP Search provides low-latency retrieval across selected enterprise knowledge bases.", - "metadata": {"knowledge_base_id": "aidp-kb-product", "section": "overview"}, + "metadata": { + "knowledge_base_id": "aidp-kb-product", "section": "overview", + "source": "产品资料", "count": 0, "enabled": False, + "tags": ["知识库", "问答"], + "standards": {"TEST_STANDARD": {"level_id": "TEST_E", "level_name": "E", "authority": "mock"}}, + }, "chunk_type": "text", "file_url": "", "pages": [1], }, + { + "id": 10002, "score": 0.94, "title": "AIDP产品介绍.pdf", + "text": "AIDP Search keeps each retrieval chunk independent so citations can identify their own metadata snapshot.", + "metadata": {"knowledge_base_id": "aidp-kb-product", "section": "citations", "count": 1, "enabled": True}, + "chunk_type": "text", "file_url": "", "pages": [2], + }, ], "aidp-kb-faq": [ { diff --git a/test/ext_components/aidp/test_aidp_citation_identity.py b/test/ext_components/aidp/test_aidp_citation_identity.py new file mode 100644 index 0000000000..df2291710c --- /dev/null +++ b/test/ext_components/aidp/test_aidp_citation_identity.py @@ -0,0 +1,37 @@ +from test.ext_components.aidp.test_aidp_search_tool import aidp_module as aidp_module +from test.ext_components.aidp.test_aidp_search_tool import aidp_tool as aidp_tool +from test.ext_components.aidp.test_aidp_search_tool import mock_observer as mock_observer + + +def test_aidp_tools_sharing_run_observer_have_distinct_citations(aidp_module, aidp_tool): + """A filename cannot disambiguate duplicate markers from separate tool instances.""" + observer = aidp_module.MessageObserver() + aidp_tool.observer = observer + second = aidp_module.AidpSearchTool( + server_url="https://aidp.example.com", api_key="test-key", + tenant_id="aidp", kds_list='["kb1"]', observer=observer, + search_method="hybrid_search", reranking_mode="performance", + ) + first_ui, first_model, _ = aidp_tool._process_records([ + {"id": "chunk-first", "title": "same.pdf", "text": "first", "metadata": {"section": 0}} + ]) + second_ui, second_model, _ = second._process_records([ + {"id": "chunk-second", "title": "same.pdf", "text": "second", "metadata": {"section": False}} + ]) + first_key = f"{first_ui[0]['tool_sign']}{first_ui[0]['cite_index']}" + second_key = f"{second_ui[0]['tool_sign']}{second_ui[0]['cite_index']}" + assert first_key != second_key + assert first_model[0]["reference_mark"] == f"[[{first_key}]]" + assert second_model[0]["reference_mark"] == f"[[{second_key}]]" + + +def test_aidp_hit_preserves_metadata_without_inventing_file_identity(aidp_tool): + metadata = {"自定义": 0, "enabled": False, "tags": [0, False, "中文"], "standards": {"code": {"level": "E"}}} + message = aidp_tool._build_chunk_message({ + "id": "chunk-is-not-file-uuid", "title": "same.pdf", "metadata": metadata, + "retrieval_source": "vector", "file_url": "/md_image/chunk.png", + }, 0).to_dict() + assert message["score_details"]["metadata"] == metadata + assert message["score_details"]["retrieval_source"] == "vector" + assert message["score_details"]["chunk_id"] == "chunk-is-not-file-uuid" + assert "download_locator" not in message["score_details"] diff --git a/test/ext_components/aidp/test_aidp_search_tool.py b/test/ext_components/aidp/test_aidp_search_tool.py index c68939a2e7..80a4a02fd0 100644 --- a/test/ext_components/aidp/test_aidp_search_tool.py +++ b/test/ext_components/aidp/test_aidp_search_tool.py @@ -116,6 +116,7 @@ def __init__(self, *args, **kwargs): def mock_observer(aidp_module): observer = MagicMock(spec=aidp_module.MessageObserver) observer.lang = "en" + observer.reserve_citations.side_effect = aidp_module.MessageObserver(lang="en").reserve_citations return observer @@ -507,6 +508,7 @@ def test_image_html_is_not_exposed_to_the_llm(self, aidp_tool): assert "aidp.example.com" not in model_results[0]["text"] assert ui_results[0]["text"] == "Description " assert ui_results[0]["image_key"] == "j1" + assert ui_results[0]["url"] == images_url[0] assert images_url == [ "https://aidp.example.com/KnowledgeBase/Tenants/aidp/" "KnowledgeBases/75/ViewImage/image-id.png" From 5d1e52c2f56986a62563488f2c47ad7fa809683b Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Wed, 14 Oct 2026 09:35:32 +0800 Subject: [PATCH 2/8] fix(chat): use HSL theme tokens for source metadata --- frontend/components/common/sourceMetadata.module.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/components/common/sourceMetadata.module.css b/frontend/components/common/sourceMetadata.module.css index 5af896a686..80dad504c1 100644 --- a/frontend/components/common/sourceMetadata.module.css +++ b/frontend/components/common/sourceMetadata.module.css @@ -25,8 +25,8 @@ height: 20px; padding: 0 8px; border-radius: 3px; - background: var(--muted); - color: var(--foreground); + background: hsl(var(--muted)); + color: hsl(var(--foreground)); font-size: 12px; font-weight: 400; line-height: 20px; @@ -61,7 +61,7 @@ padding: 4px; border: 0; background: transparent; - color: var(--muted-foreground); + color: hsl(var(--muted-foreground)); } .properties { display: grid; @@ -76,7 +76,7 @@ margin: 0; } .property dt { - color: var(--muted-foreground); + color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; } .property dd { @@ -91,7 +91,7 @@ .children { margin-top: 8px; padding-left: 10px; - border-left: 1px solid var(--border); + border-left: 1px solid hsl(var(--border)); } .json { margin: 0; From 2d29b8cfda45d3e5115046d58f4d4458cdabe1de Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Wed, 14 Oct 2026 09:46:32 +0800 Subject: [PATCH 3/8] fix(chat): proxy protected AIDP images on loopback hosts --- frontend/services/storageService.ts | 5 +++ .../workbench/StorageImageRouting.test.ts | 44 +++++++++++++++++++ .../CHAT-AIDP-SOURCES-20261014.yaml | 3 +- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/workbench/StorageImageRouting.test.ts diff --git a/frontend/services/storageService.ts b/frontend/services/storageService.ts index a2bdb34150..5ad8221f44 100644 --- a/frontend/services/storageService.ts +++ b/frontend/services/storageService.ts @@ -214,6 +214,11 @@ export function convertImageUrlToApiUrl(url: string): string { return withBasePath(url); } + // Protected AIDP images require the backend-owned credential on every host. + if (isHttpUrl && url.includes("/KnowledgeBase/Tenants/")) { + return API_ENDPOINTS.proxy.image(url); + } + // For localhost URLs in development, return original URL directly to avoid proxy issues if (isHttpUrl && /localhost|127\.0\.0\.1/i.test(url)) { return url; diff --git a/frontend/tests/workbench/StorageImageRouting.test.ts b/frontend/tests/workbench/StorageImageRouting.test.ts new file mode 100644 index 0000000000..52ae39d006 --- /dev/null +++ b/frontend/tests/workbench/StorageImageRouting.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fetchWithAuth } from "@/lib/auth"; +import { + convertImageUrlToApiUrl, + fetchImageBlob, +} from "@/services/storageService"; + +vi.mock("@/lib/auth", () => ({ fetchWithAuth: vi.fn() })); +vi.mock("@/lib/logger", () => ({ default: { info: vi.fn() } })); + +beforeEach(() => vi.mocked(fetchWithAuth).mockReset()); + +it.each(["localhost:30081", "127.0.0.1:30081", "aidp.example"])( + "fetches protected AIDP images through the backend proxy (%s)", + async (host) => { + const source = `http://${host}/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb/ViewImage/image.png`; + const image = new Blob(["sample"], { type: "image/png" }); + vi.mocked(fetchWithAuth).mockResolvedValue({ + ok: true, + headers: new Headers({ "Content-Type": "image/png" }), + blob: async () => image, + } as Response); + + const result = await fetchImageBlob(source); + + const request = new URL( + String(vi.mocked(fetchWithAuth).mock.calls[0][0]), + "http://localhost:3000" + ); + expect(request.pathname).toBe("/api/image"); + expect(request.searchParams.get("url")).toBe(source); + expect(result).toBe(image); + } +); + +it("preserves the existing local attachment image route", () => { + const source = "http://localhost:3000/nexent/attachments/local.png"; + expect(convertImageUrlToApiUrl(source)).toBe(source); +}); + +it("preserves independent AIDP image routing", () => { + const source = "/api/ind-aidp/images/agent/file.png"; + expect(convertImageUrlToApiUrl(source)).toBe(source); +}); diff --git a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml index 6d40a0deae..dcaf04a5ec 100644 --- a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml +++ b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml @@ -20,7 +20,7 @@ change: behaviors: - Preserve distinct per-hit business JSON and technical references in stream buffering, batch and legacy source writes, and history/sources restoration, including zero, false and nested values. - Keep old records without snapshots readable and preserve existing transactional, authorization and error behavior. - - Preserve image hit identity when picture events arrive out of order or multiple hits share one URL. Retain SDK retrieval identity without treating chunk IDs, filenames, metadata or image URLs as original-file locators. + - Route protected AIDP image URLs through the existing backend proxy even on loopback hosts; preserve local attachment and independent AIDP routes. Preserve image hit identity when picture events arrive out of order or multiple hits share one URL. Retain SDK retrieval identity without treating chunk IDs, filenames, metadata or image URLs as original-file locators. reason: Existing source SDK/database/history UT own these isolated serialization and write/error contracts. Strengthen their payload assertions; the existing frontend D1 adds actual citation-to-panel collaboration and forbidden download side effects, while browser/runtime evidence is recorded separately. test_selectors: - test/ext_components/aidp/test_aidp_search_tool.py @@ -29,4 +29,5 @@ change: - test/backend/services/test_conversation_management_service.py - test/backend/services/test_agent_run_final_answers.py - frontend/tests/workbench/StreamCancellation.test.ts + - frontend/tests/workbench/StorageImageRouting.test.ts notes: Real AIDP original UUID and byte verification remain unverified; the documented FusionSearch contract supplies no original download locator. Preserve existing blocked higher-layer Cases, pipeline/history logic, and local attachment behavior. Syncthing documents are read-only; the approved SPEC and local verification record are maintained outside Git. From 6296f612b8c56753298db8f610403044f5012e8f Mon Sep 17 00:00:00 2001 From: gtTree <1962086915@qq.com> Date: Wed, 14 Oct 2026 16:47:03 +0800 Subject: [PATCH 4/8] chore: add copyright headers to new citation source files --- deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql | 2 ++ frontend/components/common/sourceMetadata.module.css | 2 ++ frontend/components/common/sourceMetadata.tsx | 2 ++ .../ext_components/aidp/shared/services/aidpCitationService.ts | 2 ++ frontend/lib/source.ts | 2 ++ frontend/tests/workbench/StorageImageRouting.test.ts | 2 ++ frontend/types/source.ts | 2 ++ test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml | 2 ++ test/ext_components/aidp/test_aidp_citation_identity.py | 2 ++ 9 files changed, 18 insertions(+) diff --git a/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql index ebfd2aa192..22b3936e5c 100644 --- a/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql +++ b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql @@ -1,3 +1,5 @@ +-- Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + -- Preserve the original per-hit attributes without changing existing source identities. ALTER TABLE nexent.conversation_source_search_t ADD COLUMN IF NOT EXISTS source_metadata JSONB, diff --git a/frontend/components/common/sourceMetadata.module.css b/frontend/components/common/sourceMetadata.module.css index 80dad504c1..5e6b12149a 100644 --- a/frontend/components/common/sourceMetadata.module.css +++ b/frontend/components/common/sourceMetadata.module.css @@ -1,3 +1,5 @@ +/* Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. */ + .root { position: relative; min-width: 0; diff --git a/frontend/components/common/sourceMetadata.tsx b/frontend/components/common/sourceMetadata.tsx index 9ca9dc153d..5b0e8dc77a 100644 --- a/frontend/components/common/sourceMetadata.tsx +++ b/frontend/components/common/sourceMetadata.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; diff --git a/frontend/ext_components/aidp/shared/services/aidpCitationService.ts b/frontend/ext_components/aidp/shared/services/aidpCitationService.ts index d6179559d3..ab415d823d 100644 --- a/frontend/ext_components/aidp/shared/services/aidpCitationService.ts +++ b/frontend/ext_components/aidp/shared/services/aidpCitationService.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import aidpKnowledgeService from "./aidpKnowledgeService"; import type { AidpDownloadLocator } from "@/types/source"; diff --git a/frontend/lib/source.ts b/frontend/lib/source.ts index 31b0624d35..cdd2ec237a 100644 --- a/frontend/lib/source.ts +++ b/frontend/lib/source.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import type { AidpDownloadLocator, JsonObject, diff --git a/frontend/tests/workbench/StorageImageRouting.test.ts b/frontend/tests/workbench/StorageImageRouting.test.ts index 52ae39d006..163bee6007 100644 --- a/frontend/tests/workbench/StorageImageRouting.test.ts +++ b/frontend/tests/workbench/StorageImageRouting.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import { beforeEach, expect, it, vi } from "vitest"; import { fetchWithAuth } from "@/lib/auth"; import { diff --git a/frontend/types/source.ts b/frontend/types/source.ts index 9a67ce2ec7..d62e97038a 100644 --- a/frontend/types/source.ts +++ b/frontend/types/source.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; diff --git a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml index dcaf04a5ec..cc472779ed 100644 --- a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml +++ b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + schema_version: '1.0' change: change_id: CHAT-AIDP-SOURCES-20261014 diff --git a/test/ext_components/aidp/test_aidp_citation_identity.py b/test/ext_components/aidp/test_aidp_citation_identity.py index df2291710c..8c604f7146 100644 --- a/test/ext_components/aidp/test_aidp_citation_identity.py +++ b/test/ext_components/aidp/test_aidp_citation_identity.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + from test.ext_components.aidp.test_aidp_search_tool import aidp_module as aidp_module from test.ext_components.aidp.test_aidp_search_tool import aidp_tool as aidp_tool from test.ext_components.aidp.test_aidp_search_tool import mock_observer as mock_observer From cc22e35657f403417351b5f8dd8e650ebdf5df30 Mon Sep 17 00:00:00 2001 From: gtTree <1962086915@qq.com> Date: Wed, 14 Oct 2026 17:00:21 +0800 Subject: [PATCH 5/8] chore: add copyright headers to all changed PR code files --- backend/database/conversation_db.py | 2 ++ backend/database/db_models.py | 2 ++ backend/management/services/agent/run.py | 2 ++ backend/services/conversation_management_service.py | 2 ++ .../newchat/adapter/conversation-thread-list-adapter.tsx | 2 ++ .../app/[locale]/newchat/adapter/remote-chat-model-adapter.ts | 2 ++ frontend/app/[locale]/newchat/assistant-ui/thread.tsx | 2 ++ frontend/app/[locale]/newchat/ui/markdown-text.tsx | 2 ++ frontend/app/[locale]/newchat/ui/sources-panel.tsx | 2 ++ frontend/app/[locale]/newchat/ui/tool-fallback.tsx | 2 ++ frontend/services/storageService.ts | 2 ++ frontend/tests/workbench/StreamCancellation.test.ts | 2 ++ sdk/nexent/core/ext_components/aidp/aidp_search_tool.py | 2 ++ sdk/nexent/core/utils/observer.py | 2 ++ test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx | 2 ++ test/backend/database/test_conversation_db.py | 2 ++ test/backend/services/test_agent_run_final_answers.py | 2 ++ test/backend/services/test_conversation_management_service.py | 2 ++ test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py | 2 ++ test/ext_components/aidp/test_aidp_search_tool.py | 2 ++ 20 files changed, 40 insertions(+) diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index 6c74db5736..63bd242f0b 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import json from copy import deepcopy from datetime import datetime diff --git a/backend/database/db_models.py b/backend/database/db_models.py index e1ac743825..5be476d847 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + from sqlalchemy import ( JSON, TIMESTAMP, diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 6e881c272b..bb043eb004 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import asyncio from copy import deepcopy from http import HTTPStatus diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 9576920996..6714596190 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import json import logging diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 85891de7f0..812adb4bdc 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { FC, MutableRefObject, PropsWithChildren } from "react"; diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 02e69502b6..43ffef8e59 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 27cf286510..73c4900f6b 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { SourceAttributes } from "@/types/source"; diff --git a/frontend/app/[locale]/newchat/ui/markdown-text.tsx b/frontend/app/[locale]/newchat/ui/markdown-text.tsx index c5a270d94d..a537be44c9 100644 --- a/frontend/app/[locale]/newchat/ui/markdown-text.tsx +++ b/frontend/app/[locale]/newchat/ui/markdown-text.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { SourceAttributes } from "@/types/source"; diff --git a/frontend/app/[locale]/newchat/ui/sources-panel.tsx b/frontend/app/[locale]/newchat/ui/sources-panel.tsx index 49798e1f92..d6eb57a2f3 100644 --- a/frontend/app/[locale]/newchat/ui/sources-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/sources-panel.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import { diff --git a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx index ffa04fe2d7..1ca55563ef 100644 --- a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx +++ b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { SourceAttributes } from "@/types/source"; diff --git a/frontend/services/storageService.ts b/frontend/services/storageService.ts index 5ad8221f44..b58448ef1f 100644 --- a/frontend/services/storageService.ts +++ b/frontend/services/storageService.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import { API_BASE_URL, API_ENDPOINTS } from "./api"; import { StorageUploadResult } from "../types/chat"; import { arrayBufferToBase64 } from "@/lib/agentImportUtils"; diff --git a/frontend/tests/workbench/StreamCancellation.test.ts b/frontend/tests/workbench/StreamCancellation.test.ts index 40ebf3cda0..5f9541dd7f 100644 --- a/frontend/tests/workbench/StreamCancellation.test.ts +++ b/frontend/tests/workbench/StreamCancellation.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import { beforeEach, expect, it, vi } from "vitest"; import type { ChatModelRunOptions } from "@assistant-ui/react"; import { diff --git a/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py b/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py index 93caa6d26d..890f013bc6 100644 --- a/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py +++ b/sdk/nexent/core/ext_components/aidp/aidp_search_tool.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + """ AIDP Search Tool Performs multimodal knowledge base retrieval via the AIDP FusionSearch API. diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py index 623b83536d..50cac7c1e1 100644 --- a/sdk/nexent/core/utils/observer.py +++ b/sdk/nexent/core/utils/observer.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import json import re import threading diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx index 30706318c8..cec90bdb8d 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx @@ -1,3 +1,5 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, within, waitFor, act } from '@testing-library/react'; import { diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index 584739f4cf..f299f43cfa 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import json import sys import types diff --git a/test/backend/services/test_agent_run_final_answers.py b/test/backend/services/test_agent_run_final_answers.py index 0e3b8641fd..0566e535fc 100644 --- a/test/backend/services/test_agent_run_final_answers.py +++ b/test/backend/services/test_agent_run_final_answers.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + """Completion-event unit regressions at the stream-to-persistence boundary.""" import asyncio diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 18f745e71b..fa7820a002 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + from pathlib import Path import sys import types diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index f94c1fa3df..7a178ecda7 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + """ Standalone mock AIDP server for Nexent AIDP management endpoint testing. diff --git a/test/ext_components/aidp/test_aidp_search_tool.py b/test/ext_components/aidp/test_aidp_search_tool.py index 80a4a02fd0..52fddf7530 100644 --- a/test/ext_components/aidp/test_aidp_search_tool.py +++ b/test/ext_components/aidp/test_aidp_search_tool.py @@ -1,3 +1,5 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + import importlib.util import json import os From a2afb505aa2a1fb0ca9e550aff5c25ea2ce88dc3 Mon Sep 17 00:00:00 2001 From: gtTree <1962086915@qq.com> Date: Thu, 15 Oct 2026 15:57:56 +0800 Subject: [PATCH 6/8] fix(chat): address source panel review feedback --- .../[locale]/newchat/adapter/remote-chat-model-adapter.ts | 6 +++--- frontend/app/[locale]/newchat/ui/sources-panel.tsx | 5 ++++- frontend/public/locales/en/common.json | 1 + frontend/public/locales/zh/common.json | 1 + 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 43ffef8e59..93ae492048 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -2158,6 +2158,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Preserves cite_index for [[b1]] → source registry linkage. const searchSourcesAccumulator: SearchSource[] = []; const searchImagesAccumulator: SearchSource[] = []; + const consumedImageMetadata = new Set(); let skillFileAttachments: CompleteAttachment[] = []; let nl2a: Nl2aMessage | undefined; const acceptNl2aBoundary = (chunk: SseChunk): boolean => { @@ -2223,10 +2224,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const metadata = imageMetadata.find( (source) => source.url === imageUrl && - !searchImagesAccumulator.some( - (image) => image.imageKey === source.imageKey - ) + !consumedImageMetadata.has(source) ); + if (metadata) consumedImageMetadata.add(metadata); const imageSource: SearchSource = { ...metadata, url: imageUrl, diff --git a/frontend/app/[locale]/newchat/ui/sources-panel.tsx b/frontend/app/[locale]/newchat/ui/sources-panel.tsx index d6eb57a2f3..c4538ded9a 100644 --- a/frontend/app/[locale]/newchat/ui/sources-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/sources-panel.tsx @@ -138,6 +138,9 @@ export const SourcesPanel: FC = ({ const showSources = activeTab === "sources"; const currentItems = showSources ? sources : images; + const title = showSources + ? t("chat.sources.referenceTitle", { count: sources.length }) + : t("chat.sources.imageTitle", { count: images.length }); return (
- {(!isAidp || aidpLocator) && } + {!isAidp && ( + + )}
@@ -572,9 +584,14 @@ const SourceListItem: FC<{ ); }; -const ImageListItem: FC<{ item: PanelSourceItem }> = ({ item }) => { +const ImageListItem: FC<{ + item: PanelSourceItem; + selected: boolean; + selectionToken?: object; +}> = ({ item, selected, selectionToken }) => { const { t } = useTranslation(); const imageUrl = item.url || ""; + const itemRef = useRef(null); const [resolvedUrl, setResolvedUrl] = useState(null); const [loadError, setLoadError] = useState(false); const usesBackendStream = isLocalStorageObjectUrl(imageUrl); @@ -607,24 +624,56 @@ const ImageListItem: FC<{ item: PanelSourceItem }> = ({ item }) => { }; }, [imageUrl, usesBackendStream]); + useEffect(() => { + if (selected) { + itemRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [selected, selectionToken]); + if (!imageUrl) return null; + const cardClassName = cn( + "aui-global-search-image relative overflow-hidden rounded-md border bg-muted/50", + selected && "border-[#1677ff] ring-1 ring-[#1677ff]" + ); if (usesBackendStream && !resolvedUrl && !loadError) { return ( -
+
); } if (loadError) { return ( -
+
{item.title || imageUrl}
); } return ( -
+
+ = ({ item }) => { className="aspect-square w-full object-contain" />
- {(item.filename || (item.title && item.title !== item.url)) &&

{item.filename || item.title}

} + {(item.filename || (item.title && item.title !== item.url)) && ( +

+ {item.filename || item.title} +

+ )} - +
); diff --git a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx index 1ca55563ef..df79814cd9 100644 --- a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx +++ b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx @@ -3,6 +3,11 @@ "use client"; import type { SourceAttributes } from "@/types/source"; +import { + buildCitationDisplayIndexMap, + getCitationKey, + withCitationDisplayIndices, +} from "@/lib/citationDisplay"; import { memo } from "react"; import { useSourcesPanel } from "./sources-panel-context"; @@ -14,6 +19,7 @@ import { XCircleIcon, } from "lucide-react"; import { + useAuiState, type ToolCallMessagePartStatus, type ToolCallMessagePartProps, type ToolCallMessagePartComponent, @@ -208,6 +214,12 @@ function ToolFallbackSearchContent({ searchContent?: ToolSearchSource[]; }) { const { open } = useSourcesPanel(); + const content = useAuiState((s) => s.message.content) as readonly { + type?: string; + text?: unknown; + citeIndex?: number; + toolSign?: string; + }[]; const regularSources = (searchContent ?? []).filter((item) => !item.isImage); const panelSources: PanelSourceItem[] = regularSources.map((item, index) => ({ sourceType: @@ -231,6 +243,13 @@ function ToolFallbackSearchContent({ sourceMetadata: item.sourceMetadata, sourceReference: item.sourceReference, })); + const numberedSources = withCitationDisplayIndices( + panelSources, + buildCitationDisplayIndexMap(content, [ + ...content.filter((part) => part.type === "source"), + ...panelSources, + ]) + ); if (regularSources.length === 0) return null; return ( @@ -250,9 +269,9 @@ function ToolFallbackSearchContent({ open({ messageId: "tool-search", groupId: `tool-search-${index}`, - sources: panelSources, + sources: numberedSources, images: [], - selectedCitationKey: `${item.toolSign ?? ""}${item.citeIndex ?? index}`, + selectedCitationKey: getCitationKey(panelSources[index]), }) } > diff --git a/frontend/ext_components/aidp/shared/services/aidpCitationService.ts b/frontend/ext_components/aidp/shared/services/aidpCitationService.ts deleted file mode 100644 index ab415d823d..0000000000 --- a/frontend/ext_components/aidp/shared/services/aidpCitationService.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. - -import aidpKnowledgeService from "./aidpKnowledgeService"; -import type { AidpDownloadLocator } from "@/types/source"; - -/** Download an explicitly located original file through the existing AIDP proxy. */ -export async function downloadAidpCitation( - locator: AidpDownloadLocator, - filename: string -): Promise { - const response = await aidpKnowledgeService.downloadDoc( - locator.kds_id, - locator.file_uuid - ); - const blobUrl = URL.createObjectURL(await response.blob()); - try { - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - } finally { - URL.revokeObjectURL(blobUrl); - } -} diff --git a/frontend/lib/citationDisplay.ts b/frontend/lib/citationDisplay.ts new file mode 100644 index 0000000000..0e46c2d3f9 --- /dev/null +++ b/frontend/lib/citationDisplay.ts @@ -0,0 +1,71 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +interface CitationIdentity { + citeIndex?: number; + toolSign?: string; +} + +interface CitationTextPart { + type?: string; + text?: unknown; +} + +export function getCitationKey(item: CitationIdentity): string | undefined { + if (!Number.isFinite(item.citeIndex)) return undefined; + const toolSign = item.toolSign?.trim().toLowerCase(); + return toolSign ? `${toolSign}${item.citeIndex}` : String(item.citeIndex); +} + +/** Number cited sources by first appearance, then append uncited results. */ +export function buildCitationDisplayIndexMap( + content: readonly CitationTextPart[], + sources: readonly CitationIdentity[] = [] +): Map { + const sourceKeys = new Set(); + const legacyKeys = new Map(); + for (const source of sources) { + const key = getCitationKey(source); + if (key === undefined) continue; + sourceKeys.add(key); + if (!legacyKeys.has(source.citeIndex!)) { + legacyKeys.set(source.citeIndex!, key); + } + } + + const displayIndices = new Map(); + let nextIndex = 1; + for (const part of content) { + if (part.type !== "text" || typeof part.text !== "string") continue; + for (const match of part.text.matchAll(/\[\[([^\]]+)\]\]/g)) { + const markerKey = match[1].trim().toLowerCase(); + // Numeric legacy markers resolve to the same source as citation clicks. + const sourceKey = sourceKeys.has(markerKey) + ? markerKey + : /^\d+$/.test(markerKey) + ? (legacyKeys.get(Number(markerKey)) ?? markerKey) + : markerKey; + if (!displayIndices.has(sourceKey)) { + displayIndices.set(sourceKey, nextIndex++); + } + displayIndices.set(markerKey, displayIndices.get(sourceKey)!); + } + } + for (const key of sourceKeys) { + if (!displayIndices.has(key)) displayIndices.set(key, nextIndex++); + } + return displayIndices; +} + +/** Add presentation numbers without changing the original citation identity. */ +export function withCitationDisplayIndices( + sources: readonly T[], + displayIndices: ReadonlyMap +): Array { + return sources.map((source) => { + const key = getCitationKey(source); + return { + ...source, + displayIndex: key === undefined ? undefined : displayIndices.get(key), + }; + }); +} diff --git a/frontend/lib/source.ts b/frontend/lib/source.ts index cdd2ec237a..6907983e2b 100644 --- a/frontend/lib/source.ts +++ b/frontend/lib/source.ts @@ -1,7 +1,6 @@ // Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. import type { - AidpDownloadLocator, JsonObject, SourceAttributes, } from "@/types/source"; @@ -70,33 +69,6 @@ export function getSourceAttributes( }; } -export function getAidpDownloadLocator( - source: SourceAttributes -): AidpDownloadLocator | undefined { - if ( - source.searchType !== "aidp_search" && - !(!source.searchType && source.toolSign?.toLowerCase() === "j") - ) - return undefined; - const locator = source.sourceReference?.download_locator; - if (!locator || typeof locator !== "object") return undefined; - const value = locator as Record; - if ( - value.provider !== "aidp" || - typeof value.kds_id !== "string" || - !value.kds_id.trim() - ) - return undefined; - if ( - typeof value.file_uuid !== "string" || - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - value.file_uuid - ) - ) - return undefined; - return value as unknown as AidpDownloadLocator; -} - export function isAidpSource(source: SourceAttributes): boolean { return getSourceProvider(source.searchType, source.toolSign) === "AIDP"; } diff --git a/frontend/tests/component/citationDisplay.test.tsx b/frontend/tests/component/citationDisplay.test.tsx new file mode 100644 index 0000000000..9abc9752f6 --- /dev/null +++ b/frontend/tests/component/citationDisplay.test.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +import { describe, expect, it } from "vitest"; +import { + buildCitationDisplayIndexMap, + getCitationKey, + withCitationDisplayIndices, +} from "@/lib/citationDisplay"; + +describe("message-wide citation numbering", () => { + it("counts image citations before a later text source without changing identities", () => { + const sources = [1, 2, 3, 4].map((citeIndex) => + Object.freeze({ citeIndex, toolSign: "a", isImage: citeIndex > 2 }) + ); + const indices = buildCitationDisplayIndexMap( + [ + { + type: "text", + text: "Text [[a1]], figures [[a3]][[a4]], guideline [[a2]]", + }, + ], + sources + ); + const numbered = withCitationDisplayIndices(sources, indices); + expect(numbered.map((source) => source.displayIndex)).toEqual([1, 4, 2, 3]); + expect(numbered.map(getCitationKey)).toEqual(["a1", "a2", "a3", "a4"]); + expect(sources[1]).not.toHaveProperty("displayIndex"); + }); + + it("keeps equal raw indices from different tools distinct", () => { + const sources = [ + { citeIndex: 1, toolSign: "a" }, + { citeIndex: 1, toolSign: "b" }, + ]; + const indices = buildCitationDisplayIndexMap( + [{ type: "text", text: "[[b1]][[a1]][[b1]]" }], + sources + ); + expect( + withCitationDisplayIndices(sources, indices).map( + (item) => item.displayIndex + ) + ).toEqual([2, 1]); + }); + + it("shares one number for normalized markers and numeric legacy aliases", () => { + const sources = [{ citeIndex: 7, toolSign: "A" }]; + const indices = buildCitationDisplayIndexMap( + [{ type: "text", text: "[[7]][[ A7 ]][[a7]]" }], + sources + ); + expect(indices.get("7")).toBe(1); + expect(indices.get("a7")).toBe(1); + expect(withCitationDisplayIndices(sources, indices)[0].displayIndex).toBe( + 1 + ); + }); + + it("prefers an exact numeric identity over a tool-prefixed legacy fallback", () => { + const sources = [{ citeIndex: 1, toolSign: "a" }, { citeIndex: 1 }]; + const indices = buildCitationDisplayIndexMap( + [{ type: "text", text: "[[1]][[a1]]" }], + sources + ); + expect( + withCitationDisplayIndices(sources, indices).map( + (item) => item.displayIndex + ) + ).toEqual([2, 1]); + }); + + it("reserves unknown markers and appends uncited sources without number collisions", () => { + const sources = [ + { citeIndex: 9, toolSign: "a" }, + { citeIndex: 2, toolSign: "b" }, + ]; + const indices = buildCitationDisplayIndexMap( + [{ type: "text", text: "[[missing8]][[b2]]" }], + sources + ); + expect(indices.get("missing8")).toBe(1); + expect( + withCitationDisplayIndices(sources, indices).map( + (item) => item.displayIndex + ) + ).toEqual([3, 2]); + }); + + it("ignores non-text parts and leaves invalid source identities unnumbered", () => { + const sources = [{ citeIndex: undefined }, { citeIndex: Number.NaN }]; + const indices = buildCitationDisplayIndexMap( + [ + { type: "source", text: "[[a1]]" }, + { type: "text", text: undefined }, + ], + sources + ); + expect(indices.size).toBe(0); + expect( + withCitationDisplayIndices(sources, indices).map( + (item) => item.displayIndex + ) + ).toEqual([undefined, undefined]); + }); +}); diff --git a/frontend/types/source.ts b/frontend/types/source.ts index d62e97038a..bcb0a6aee3 100644 --- a/frontend/types/source.ts +++ b/frontend/types/source.ts @@ -7,12 +7,6 @@ export interface JsonObject { [key: string]: JsonValue; } -export interface AidpDownloadLocator { - provider: "aidp"; - kds_id: string; - file_uuid: string; -} - export interface SourceAttributes { searchType?: string; toolSign?: string; diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml index e3c40d6825..6f55281a93 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/case.yaml @@ -8,6 +8,7 @@ case: - F-065-RULE-01 - F-065-RULE-02 - F-065-RULE-03 + - F-065-RULE-04 title: 引用角标组合key匹配、旧角标兼容、句级高亮与来源字段透传|核心流程 type: FE-COMP priority: P1 @@ -49,8 +50,11 @@ case: action: 注入同名 AIDP 多切片且 metadata 含零、false、数组、嵌套对象的来源,点击各自引用并检查面板和属性悬浮内容;无 metadata 时检查没有空区域。 expected: 每条命中的原始 metadata、searchType 与技术引用完整透传;提供方显示 AIDP 而业务 source 原值保留;属性可通过悬浮、聚焦和点击访问,操作不触发文件预览。 - order: 9 - action: 对无定位的 AIDP 来源注入文件名、切片或图片 URL,检查无下载按钮和无本地存储调用;对有效内部定位检查复用 AIDP 下载服务,对本地附件检查原下载行为。 - expected: AIDP 无有效原文件定位时无下载动作;有效内部定位走已有 AIDP 服务,本地附件仍走已有存储下载。 + action: 为 AIDP 来源提供文件名、业务 metadata、切片 ID、图片 URL 和其他可能的下载地址,检查不显示下载按钮且不调用下载服务;对本地附件检查原下载行为。 + expected: AIDP 来源始终没有下载动作;本地附件仍走已有存储下载。 + - order: 10 + action: 注入原始来源 a1、a2 和图片 a3、a4,正文按 a1、a3、a4、a2 顺序引用。点击 a2 再点击 a4,检查角标、悬浮提示与真实来源面板的编号和选中身份。 + expected: a2 在正文、悬浮提示与来源卡均显示 4;图片卡 a3、a4 显示 2、3,点击 a4 选中第二张图片,原始引用键仍为 a2、a4。 expected_results: - 组合 key 精确匹配各自来源,a1 与 b1 互不混淆并返回对应 SearchSource。 - 纯数字旧角标回退匹配到相等 citeIndex 来源,历史会话保持可读。 @@ -58,9 +62,11 @@ case: - 高亮开启与关闭情况下点击角标均能用 selectedCitationKey 选中并滚动到来源。 - retrievalHighlightTerms/publishedDate 透传无丢失;匹配不到来源时 loading 正确且无未处理异常。 - metadata 和技术引用按命中完整透传,原始 key/value 可访问,提供方与业务 source 分开且空 metadata 无区域。 - - AIDP 下载遵循有效定位和既有服务契约;本地附件回归通过。 + - AIDP 引用来源不提供下载动作;本地附件回归通过。 + - 图片参与回答统一编号;正文、提示和两个侧栏标签页编号一致且原始引用身份不变。 forbidden_side_effects: - - 无定位 AIDP 不调用本地存储或按文件名、切片 ID、图片地址猜原文件;查看属性不触发预览或下载。 + - AIDP 来源不调用本地存储或按文件名、切片 ID、图片地址猜原文件;查看属性不触发预览或下载。 + - 显示编号不得修改 citeIndex、toolSign 或 selectedCitationKey,也不得在切换来源和图片标签页时重新编号。 acceptance: - id: AC-01 description: Distinct composite keys and numeric legacy compatibility remain correct. @@ -80,10 +86,15 @@ case: expected_results: [6] forbidden_side_effects: [1] - id: AC-05 - description: AIDP downloads require explicit original identity and never fall back to local attachments; local downloads remain available. + description: AIDP citation downloads remain unavailable until retrieval exposes a verified original-file identity; local downloads remain available. steps: [9] expected_results: [7] forbidden_side_effects: [1] + - id: AC-06 + description: Inline citations, hover labels and source/image cards share message-wide numbering without changing citation identity. + steps: [10] + expected_results: [8] + forbidden_side_effects: [2] source: legacy_workbook_sha256: aa4b32414d30f12b3b64e14236268145e556bd7cfc5d3eb8df80731608ebc022 legacy_sheet: 02_测试用例基线 @@ -92,6 +103,7 @@ case: inputs: - 无(纯前端组件逻辑,固定测试数据内联于测试代码) assertions: + - 正文、悬浮提示与来源和图片卡片共用首次引用顺序编号,显示编号不修改原始引用身份。 - 组合 key 精确匹配各自来源,a1 与 b1 互不混淆并返回对应 SearchSource。 - 纯数字旧角标回退匹配到相等 citeIndex 来源,历史会话保持可读。 - 开启句级高亮时 citationContext 返回角标前一句完整 scope 文本,且不跨段落/跨表格单元扩展。 diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml index 1243809f33..8cfe7117aa 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/execution.yaml @@ -23,5 +23,7 @@ acceptance_bindings: - per-hit attributes and safe citation downloads > keeps same-name hit snapshots distinct from citation click through the real panel - per-hit attributes and safe citation downloads > omits empty attributes and supports focus access to a complete raw snapshot AC-05: - - per-hit attributes and safe citation downloads > never enables AIDP downloads from names, business metadata, chunk IDs or image URLs - - per-hit attributes and safe citation downloads > uses the existing AIDP proxy for explicit locators and preserves local downloads + - per-hit attributes and safe citation downloads > never enables unsupported AIDP citation downloads + - per-hit attributes and safe citation downloads > preserves local downloads while AIDP citation downloads remain unsupported + AC-06: + - SourcesPanel selection and passthrough > keeps citation, tooltip and panel numbers aligned across source and image tabs diff --git a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx index cec90bdb8d..99f1a54fdb 100644 --- a/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx +++ b/test-e2e/cases/UT-FE-AUTO-82166F602745F6D7/test.test.tsx @@ -15,7 +15,6 @@ const h = vi.hoisted(() => ({ open: vi.fn(), citeMarkerProps: [] as any[], storageDownload: vi.fn(), - aidpDownload: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -43,10 +42,6 @@ vi.mock('@/services/storageService', () => ({ storageService: { downloadFileWithAuth: h.storageDownload }, })); -vi.mock('@/ext_components/aidp/shared/services/aidpKnowledgeService', () => ({ - default: { downloadDoc: h.aidpDownload }, -})); - vi.mock('@assistant-ui/react-markdown', () => ({ unstable_memoizeMarkdownComponents: (c: any) => c, MarkdownTextPrimitive: () => null, @@ -99,7 +94,9 @@ vi.mock('@/components/ui/button', () => ({ })); vi.mock('@/components/common/highlightedSourceText', () => ({ - CiteIndexBadge: () => null, + CiteIndexBadge: ({ index }: { index?: number }) => Number.isFinite(index) + ? {index} + : null, HighlightedChunkText: () => null, })); @@ -120,7 +117,6 @@ beforeEach(() => { h.open.mockClear(); h.citeMarkerProps.length = 0; h.storageDownload.mockReset().mockResolvedValue(undefined); - h.aidpDownload.mockReset().mockResolvedValue({ blob: async () => new Blob(['mock sample']) }); }); describe('getCitationKey combined citation key generation', () => { @@ -278,6 +274,41 @@ describe('citation click behaviour', () => { }); describe('SourcesPanel selection and passthrough', () => { + it('keeps citation, tooltip and panel numbers aligned across source and image tabs', async () => { + h.aui.message.content = [ + { type: 'text', text: 'Text [[a1]], figures [[a3]][[a4]], guideline [[a2]]' }, + { type: 'source', sourceType: 'document', citeIndex: 1, toolSign: 'a', title: 'Text' }, + { type: 'source', sourceType: 'document', citeIndex: 2, toolSign: 'a', title: 'Guideline' }, + { type: 'source', citeIndex: 3, toolSign: 'a', title: 'First figure', url: 'https://example.com/first.png', isImage: true }, + { type: 'source', citeIndex: 4, toolSign: 'a', title: 'Second figure', url: 'https://example.com/second.png', isImage: true }, + ]; + const marker = render(); + const markerProps = h.citeMarkerProps.at(-1); + expect(markerProps.displayIndex).toBe(4); + expect(markerProps.sourceIndex).toBe(2); + expect(markerProps.label).toBe('chat.sources.knowledgeBase 4'); + markerProps.onClick(null); + const payload = h.open.mock.calls.at(-1)![0]; + expect(payload.selectedCitationKey).toBe('a2'); + expect(payload.sources.map((item: any) => item.citeIndex)).toEqual([1, 2]); + const panel = render( {}} />); + expect(within(panel.container).getAllByTestId('citation-index-badge').map((node) => node.textContent)) + .toEqual(['1', '4']); + expect(screen.getByText('Guideline').closest('li')?.className).toContain('border-[#1677ff]'); + + marker.rerender(); + const imageMarkerProps = h.citeMarkerProps.at(-1); + expect(imageMarkerProps.displayIndex).toBe(3); + imageMarkerProps.onClick(null); + const imagePayload = h.open.mock.calls.at(-1)![0]; + panel.rerender( {}} />); + await waitFor(() => expect(within(panel.container).getAllByTestId('citation-index-badge') + .map((node) => node.textContent)).toEqual(['2', '3'])); + expect(screen.getByText('Second figure').closest('[data-citation-selected="true"]')).toBeTruthy(); + expect(imagePayload.selectedCitationKey).toBe('a4'); + marker.unmount(); + }); + it('selects and scrolls to the source matching selectedCitationKey and renders publishedDate', () => { const original = HTMLElement.prototype.scrollIntoView; const scrollSpy = vi.fn(); @@ -355,7 +386,6 @@ describe('per-hit attributes and safe citation downloads', () => { expect(within(panel.container).getAllByText('chat.sources.knowledgeBase · AIDP')).toHaveLength(2); expect(preview).not.toHaveBeenCalled(); expect(h.storageDownload).not.toHaveBeenCalled(); - expect(h.aidpDownload).not.toHaveBeenCalled(); preview.mockRestore(); }); @@ -374,45 +404,23 @@ describe('per-hit attributes and safe citation downloads', () => { await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('false')); }); - it('never enables AIDP downloads from names, business metadata, chunk IDs or image URLs', () => { - const uuid = '12345678-1234-1234-1234-123456789abc'; + it('never enables unsupported AIDP citation downloads', () => { const panel = render( {}} />); expect(within(panel.container).queryByRole('button', { name: 'chat.sources.download' })).toBeNull(); expect(h.storageDownload).not.toHaveBeenCalled(); - expect(h.aidpDownload).not.toHaveBeenCalled(); }); - it('uses the existing AIDP proxy for explicit locators and preserves local downloads', async () => { - const uuid = '12345678-1234-1234-1234-123456789abc'; - const create = URL.createObjectURL, revoke = URL.revokeObjectURL; - URL.createObjectURL = vi.fn(() => 'blob:unit-download'); - URL.revokeObjectURL = vi.fn(); - const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); - try { - const panel = render( {}} />); - const buttons = within(panel.container).getAllByRole('button', { name: 'chat.sources.download' }); - fireEvent.click(buttons[0]); - await waitFor(() => expect(h.aidpDownload).toHaveBeenCalledWith('kb-explicit', uuid)); - expect(h.storageDownload).not.toHaveBeenCalled(); - await waitFor(() => expect(click).toHaveBeenCalledOnce()); - fireEvent.click(buttons[1]); - await waitFor(() => expect(h.storageDownload).toHaveBeenCalledWith('attachments/local.pdf', 'local.pdf')); - expect(h.aidpDownload).toHaveBeenCalledOnce(); - h.aidpDownload.mockRejectedValueOnce(new Error('AIDP file unavailable')); - fireEvent.click(buttons[0]); - await screen.findByText('chat.sources.downloadError'); - expect(h.storageDownload).toHaveBeenCalledOnce(); - fireEvent.click(buttons[0]); - await waitFor(() => expect(h.aidpDownload).toHaveBeenCalledTimes(3)); - await waitFor(() => expect(within(panel.container).queryByText('chat.sources.downloadError')).toBeNull()); - } finally { click.mockRestore(); URL.createObjectURL = create; URL.revokeObjectURL = revoke; } + it('preserves local downloads while AIDP citation downloads remain unsupported', async () => { + const panel = render( {}} />); + const buttons = within(panel.container).getAllByRole('button', { name: 'chat.sources.download' }); + expect(buttons).toHaveLength(1); + fireEvent.click(buttons[0]); + await waitFor(() => expect(h.storageDownload).toHaveBeenCalledWith('attachments/local.pdf', 'local.pdf')); }); }); diff --git a/test-e2e/changes/bugs/CHAT-CITATION-NUMBERING-20261015.yaml b/test-e2e/changes/bugs/CHAT-CITATION-NUMBERING-20261015.yaml new file mode 100644 index 0000000000..0b1585d17f --- /dev/null +++ b/test-e2e/changes/bugs/CHAT-CITATION-NUMBERING-20261015.yaml @@ -0,0 +1,34 @@ +schema_version: '1.0' +change: + change_id: CHAT-CITATION-NUMBERING-20261015 + change_type: bugfix + bug_id: CHAT-CITATION-NUMBERING-20261015 + title: Align inline citation numbers with source and image cards + description: Share first-appearance numbering across citation markers, hover labels and every source-panel entry point while retaining original citation identities. + observed_behavior: A text hit referenced after two figures displays 4 in the answer but its source card displays the raw retrieval index 2. + expected_behavior: The same hit displays 4 in both places; images share the same numbering sequence and clicks still match the original toolSign plus citeIndex. + escaped_stage: D4 + coverage_gap: + type: missing_assertion + description: Existing citation component coverage checks key matching and scrolling but does not assert display-number agreement when image citations precede a text source. + feature_contract_changed: true + affected_features: + existing: [] + added: [] + modified: [F-065] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [UT-FE-AUTO-82166F602745F6D7] + retired: [] + traditional_ut: + decision: add + behaviors: + - Image citations count toward message-wide first-appearance numbering without mutating source identities. + - Equal indices from different tools remain distinct; normalized markers and numeric legacy aliases share the correct identity. + - Unknown markers and uncited hits cannot collide; invalid identities remain unnumbered. + reason: Pure mapping boundaries belong in frontend UT. Strengthen the existing D1 for actual citation-to-panel collaboration; no new formal Case is needed. + test_selectors: + - frontend/tests/component/citationDisplay.test.tsx + notes: 'Design scope: answer citation numbering through inline markers, hover labels, source summary/tool-card entry points and both panel tabs. Source metadata, downloads, persistence and backend APIs are unchanged. Evidence baseline: current F-065, UT-FE-AUTO-82166F602745F6D7 and newchat callers. The sibling nexent-doc repository is unavailable in this checkout, so its SPEC baseline cannot be searched or updated; do not invent a replacement document repository. D2/D3/D5 are not affected because this fix changes presentation only. Browser verification uses the existing historical conversation; UT and formal D1 execution are reported separately.' diff --git a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml index cc472779ed..1125f70944 100644 --- a/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml +++ b/test-e2e/changes/requirements/CHAT-AIDP-SOURCES-20261014.yaml @@ -5,8 +5,8 @@ change: change_id: CHAT-AIDP-SOURCES-20261014 change_type: requirement requirement_id: CHAT-AIDP-SOURCES-20261014 - title: Persist per-hit citation metadata and prevent AIDP local download fallback - description: Show two-row raw-key source attributes with hover details, restore metadata and provider/reference snapshots in history, and require explicit original-file identity for AIDP downloads. + title: Persist per-hit citation metadata and hide unsupported AIDP citation downloads + description: Show two-row raw-key source attributes with hover details, restore metadata and provider/reference snapshots in history, hide unsupported AIDP citation downloads, and preserve local attachment downloads. affected_features: existing: [F-065] added: [] @@ -32,4 +32,4 @@ change: - test/backend/services/test_agent_run_final_answers.py - frontend/tests/workbench/StreamCancellation.test.ts - frontend/tests/workbench/StorageImageRouting.test.ts - notes: Real AIDP original UUID and byte verification remain unverified; the documented FusionSearch contract supplies no original download locator. Preserve existing blocked higher-layer Cases, pipeline/history logic, and local attachment behavior. Syncthing documents are read-only; the approved SPEC and local verification record are maintained outside Git. + notes: The documented FusionSearch contract supplies no original-file identity, so citation downloads remain unavailable until the backend exposes and validates that contract; preserve local attachment behavior. Preserve existing blocked higher-layer Cases and pipeline/history logic. Syncthing documents are read-only; the approved SPEC and local verification record are maintained outside Git. diff --git a/test-e2e/features/F-065/feature.yaml b/test-e2e/features/F-065/feature.yaml index 46ca2dd9f7..d623ea52ea 100644 --- a/test-e2e/features/F-065/feature.yaml +++ b/test-e2e/features/F-065/feature.yaml @@ -14,7 +14,9 @@ feature: - rule_id: F-065-RULE-02 description: 引用来源按每条命中保留业务 metadata、技术引用和检索提供方;实时与历史一致,原始字段两行标签及悬浮详情可访问,业务 source 不覆盖提供方。 - rule_id: F-065-RULE-03 - description: AIDP 原文下载必须具备明确有效的原文件定位并复用已有代理;缺定位不提供下载动作、不按切片或文件名走本地存储,本地附件功能保留。 + description: 当前 AIDP 检索来源不支持原文下载,来源面板不显示下载动作;本地附件下载功能保留。 + - rule_id: F-065-RULE-04 + description: 正文角标、引用悬浮提示、侧栏来源与图片卡片使用同一回答首次引用顺序的显示编号,图片参与统一编号;未引用命中随后编号,原始引用键不变,角标和来源汇总入口保持一致。 required_test_stages: - D1 source: diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index f299f43cfa..bf92a045c1 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -1255,8 +1255,8 @@ def test_persist_assistant_run_batch_uses_one_transaction(monkeypatch): "source_title": "Result", "source_location": "https://example.com", "source_content": "content", - "source_metadata": {"count": 0, "enabled": False, "nested": {"tags": [0, False]}}, - "source_reference": {"chunk_id": "chunk-first", "retrieval_source": "vector"}, + "aidp_metadata": {"count": 0, "enabled": False, "nested": {"tags": [0, False]}}, + "aidp_source_reference": {"chunk_id": "chunk-first", "retrieval_source": "vector"}, "cite_index": 1, "search_type": "web_search", "tool_sign": "web", @@ -1274,8 +1274,8 @@ def test_persist_assistant_run_batch_uses_one_transaction(monkeypatch): ctx.__exit__.assert_called_once() assert session.execute.call_count == 5 source_row = session.execute.call_args_list[2].args[1][0] - assert source_row["source_metadata"] == {"count": 0, "enabled": False, "nested": {"tags": [0, False]}} - assert source_row["source_reference"] == {"chunk_id": "chunk-first", "retrieval_source": "vector"} + assert source_row["aidp_metadata"] == {"count": 0, "enabled": False, "nested": {"tags": [0, False]}} + assert source_row["aidp_source_reference"] == {"chunk_id": "chunk-first", "retrieval_source": "vector"} assert source_row["unit_id"] == 101 assert parent.message_content == "done" assert parent.status == "completed" @@ -2791,16 +2791,16 @@ def test_create_source_search_with_unit_id(monkeypatch, fresh_insert_mock): "search_type": "search", "tool_sign": "web_search", "unit_id": 42, - "source_metadata": {"count": 0, "enabled": False, "list": ["a", "b"]}, - "source_reference": {"chunk_id": "chunk-42"}, + "aidp_metadata": {"count": 0, "enabled": False, "list": ["a", "b"]}, + "aidp_source_reference": {"chunk_id": "chunk-42"}, }, user_id="actor", ) assert search_id == 88 assert fresh_insert_mock["unit_id"] == 42 - assert fresh_insert_mock["source_metadata"] == {"count": 0, "enabled": False, "list": ["a", "b"]} - assert fresh_insert_mock["source_reference"] == {"chunk_id": "chunk-42"} + assert fresh_insert_mock["aidp_metadata"] == {"count": 0, "enabled": False, "list": ["a", "b"]} + assert fresh_insert_mock["aidp_source_reference"] == {"chunk_id": "chunk-42"} def test_create_source_search_with_published_date(monkeypatch, fresh_insert_mock): diff --git a/test/backend/services/test_agent_run_final_answers.py b/test/backend/services/test_agent_run_final_answers.py index 0566e535fc..9f6dee9590 100644 --- a/test/backend/services/test_agent_run_final_answers.py +++ b/test/backend/services/test_agent_run_final_answers.py @@ -101,9 +101,9 @@ async def test_search_hit_snapshots_survive_stream_buffering_without_filename_gr assert json.loads(published[0]["content"]) == hits records = batch["search_records"] assert len(records) == 2 - assert [record["source_metadata"] for record in records] == metadata - assert [record["source_reference"]["chunk_id"] for record in records] == ["chunk-0", "chunk-1"] - assert all("metadata" not in record["source_reference"] for record in records) + assert [record["aidp_metadata"] for record in records] == metadata + assert [record["aidp_source_reference"]["chunk_id"] for record in records] == ["chunk-0", "chunk-1"] + assert all("metadata" not in record["aidp_source_reference"] for record in records) unit = batch["message_units"][0] assert unit["tool_call_id"] == "call-search" and unit["invocation_id"] == "child" assert all(record["unit_index"] == unit["unit_index"] for record in records) diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index fa7820a002..d3cfd7b802 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -1053,8 +1053,8 @@ def test_get_sources_service_by_message(self, mock_get_images, mock_get_searches "cite_index": 1, "search_type": "web_search", "tool_sign": "web_search", - "source_metadata": {"count": 0, "enabled": False, "nested": {"values": [0, False]}}, - "source_reference": {"chunk_id": "chunk-source", "retrieval_source": "vector"}, + "aidp_metadata": {"count": 0, "enabled": False, "nested": {"values": [0, False]}}, + "aidp_source_reference": {"chunk_id": "chunk-source", "retrieval_source": "vector"}, } ] mock_get_searches.return_value = mock_searches @@ -1737,14 +1737,14 @@ def test_with_search_records(self, mock_get): "score_overall": 0.9, "score_accuracy": 0.8, "score_semantic": 0.7, "retrieval_highlight_terms": ["Content"], "cite_index": 1, "search_type": "aidp_search", "tool_sign": "j", - "source_metadata": {"section": 0, "enabled": False, "tags": [0, False]}, - "source_reference": {"chunk_id": "chunk-first"}}, + "aidp_metadata": {"section": 0, "enabled": False, "tags": [0, False]}, + "aidp_source_reference": {"chunk_id": "chunk-first"}}, {"unit_id": 10, "message_id": 2, "source_title": "Doc 1", "source_content": "Second content", "source_type": "file", "source_location": "", "published_date": None, "score_overall": 0.8, "score_accuracy": None, "score_semantic": None, "cite_index": 2, "search_type": "aidp_search", "tool_sign": "j", - "source_metadata": {"section": 1, "standards": {"code": {"level": "E"}}}, - "source_reference": {"chunk_id": "chunk-second"}}, + "aidp_metadata": {"section": 1, "standards": {"code": {"level": "E"}}}, + "aidp_source_reference": {"chunk_id": "chunk-second"}}, ], "image_records": [] } From 9fa65ffbc114a75e9032e8e3df9370ff11cbfb5a Mon Sep 17 00:00:00 2001 From: gtTree <1962086915@qq.com> Date: Thu, 15 Oct 2026 20:19:15 +0800 Subject: [PATCH 8/8] refactor(db): simplify AIDP snapshot migration --- .../v2.7.1_002_chat_source_snapshots.sql | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql index 6983d0a96e..975da3f9d4 100644 --- a/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql +++ b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql @@ -1,49 +1,10 @@ -- Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. --- Preserve AIDP retrieval attributes while using explicit AIDP-specific column names. +-- Add AIDP-specific retrieval snapshots for each search hit. ALTER TABLE nexent.conversation_source_search_t ADD COLUMN IF NOT EXISTS aidp_metadata JSONB, ADD COLUMN IF NOT EXISTS aidp_source_reference JSONB; -DO $$ -DECLARE - legacy_column TEXT; -BEGIN - FOREACH legacy_column IN ARRAY ARRAY['retrieval_metadata', 'source_metadata'] LOOP - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'nexent' - AND table_name = 'conversation_source_search_t' - AND column_name = legacy_column - ) THEN - EXECUTE format( - 'UPDATE nexent.conversation_source_search_t ' - 'SET aidp_metadata = COALESCE(aidp_metadata, %I)', - legacy_column - ); - EXECUTE format( - 'ALTER TABLE nexent.conversation_source_search_t DROP COLUMN %I', - legacy_column - ); - END IF; - END LOOP; - - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'nexent' - AND table_name = 'conversation_source_search_t' - AND column_name = 'source_reference' - ) THEN - UPDATE nexent.conversation_source_search_t - SET aidp_source_reference = COALESCE(aidp_source_reference, source_reference); - ALTER TABLE nexent.conversation_source_search_t - DROP COLUMN source_reference; - END IF; -END -$$; - COMMENT ON COLUMN nexent.conversation_source_search_t.aidp_metadata IS 'Opaque AIDP metadata snapshot returned for this retrieval hit'; COMMENT ON COLUMN nexent.conversation_source_search_t.aidp_source_reference