diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index cb40c759c3..f0de4c9c93 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 @@ -63,6 +65,8 @@ class SearchRecord(TypedDict): score_accuracy: Optional[float] score_semantic: Optional[float] retrieval_highlight_terms: Optional[List[str]] + aidp_metadata: Optional[Dict[str, Any]] + aidp_source_reference: Optional[Dict[str, Any]] published_date: Optional[datetime] cite_index: Optional[int] search_type: Optional[str] @@ -476,6 +480,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"), + "aidp_metadata": record.get("aidp_metadata"), + "aidp_source_reference": record.get("aidp_source_reference"), "published_date": record.get("published_date"), "cite_index": record.get("cite_index"), "search_type": record.get("search_type"), @@ -1949,6 +1956,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 'aidp_metadata' in search_data: + data["aidp_metadata"] = search_data['aidp_metadata'] + if 'aidp_source_reference' in search_data: + data["aidp_source_reference"] = search_data['aidp_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..dd979eecc4 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, @@ -352,6 +354,14 @@ class ConversationSourceSearch(TableBase): JSONB, doc="Exact lexical terms returned by the retrieval engine for source highlighting", ) + aidp_metadata = Column( + JSONB, nullable=True, + comment="Opaque AIDP metadata snapshot returned for this retrieval hit", + ) + aidp_source_reference = Column( + JSONB, nullable=True, + comment="AIDP source reference attributes for this retrieval hit, excluding 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 dcd3daa1af..306fd6cd0d 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 @@ -693,6 +695,12 @@ async def _iter_run_chunks(): "source_title": result.get("title", ""), "source_location": result.get("url", ""), "source_content": result.get("text", ""), + "aidp_metadata": result.get("score_details", {}).get("metadata"), + "aidp_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 ad2f7f1d5f..3acdb4707c 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 @@ -829,6 +831,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("aidp_source_reference") or {}) + if record.get("aidp_metadata") is not None: + search_item["score_details"]["metadata"] = record["aidp_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 +1097,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("aidp_source_reference") or {}) + if record.get("aidp_metadata") is not None: + search_item["score_details"]["metadata"] = record["aidp_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..975da3f9d4 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_002_chat_source_snapshots.sql @@ -0,0 +1,11 @@ +-- Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +-- 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; + +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 + IS 'AIDP source reference attributes for this retrieval hit, excluding metadata'; 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..d62fb0fdc2 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -1,7 +1,10 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; import type { FC, MutableRefObject, PropsWithChildren } from "react"; import { useMemo } from "react"; +import { getSourceAttributes } from "@/lib/source"; import { ExportedMessageRepository, RuntimeAdapterProvider, @@ -187,7 +190,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 +202,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 +211,7 @@ const toToolSearchItem = (value: unknown) => { toolSign, isImage: Boolean(imageMetadata), retrievalHighlightTerms: getRetrievalHighlightTerms(item.score_details), + ...getSourceAttributes(item.search_type, item.score_details), } : null; }; @@ -423,23 +429,35 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { const item = searchItem as Record; const url = (item.url as string | undefined) ?? ""; const filename = (item.filename as string | undefined) ?? ""; - const title = (item.title as string | undefined) || filename || url; + const imageMetadata = parseImageMetadata(item.text); + const resolvedUrl = imageMetadata?.image_url || url; + const title = + (item.title as string | undefined) || + filename || + imageMetadata?.source_file || + resolvedUrl; if (url || filename || title) { const derivedImageKey = `${item.tool_sign ?? ""}${item.cite_index ?? ""}`; const isImage = (item.score_details as Record | undefined) ?.chunk_type === "image" || + Boolean(imageMetadata) || persistedAnswerImageKeys.includes(derivedImageKey); sources.push({ citeIndex: (item.cite_index as number | undefined) ?? 0, - url, + url: resolvedUrl, title, - text: item.text as string | undefined, + text: imageMetadata + ? undefined + : (item.text as string | undefined), publishedDate: item.published_date as string | undefined, sourceType: item.source_type as string | undefined, searchType: item.search_type as string | undefined, toolSign: item.tool_sign as string | undefined, filename, + sourceFile: + (item.source_file as string | undefined) || + imageMetadata?.source_file, downloadUrl: item.download_url as string | undefined, objectName: item.object_name as string | undefined, isImage, @@ -449,6 +467,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { retrievalHighlightTerms: getRetrievalHighlightTerms( item.score_details ), + ...getSourceAttributes(item.search_type, item.score_details), }); } } @@ -588,6 +607,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 +617,69 @@ 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; + const metadata = parseImageMetadata(item.text); + return (metadata?.image_url || 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 imageMetadata = metadata + ? parseImageMetadata(metadata.text) + : null; + 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) || + imageMetadata?.source_file || + imageUrl; + const imagePart: any = { + ...getSourceAttributes( + metadata?.search_type, + metadata?.score_details + ), + type: "source", + sourceType: "url", + url: imageUrl, + title, + text: imageMetadata + ? undefined + : (metadata?.text as string | undefined), + filename: metadata?.filename as string | undefined, + sourceFile: + (metadata?.source_file as string | undefined) || + imageMetadata?.source_file, + 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 +835,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 +844,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 ); @@ -1235,8 +1291,10 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { const scoreDetails = item.score_details as Record | undefined; const searchImageKey = `${item.tool_sign ?? ""}${item.cite_index ?? ""}`; + const imageMetadata = parseImageMetadata(item.text); if ( scoreDetails?.chunk_type === "image" || + imageMetadata || answerImageKeys.includes(searchImageKey) ) continue; @@ -1253,6 +1311,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 +1319,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..93ae492048 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 { @@ -14,6 +16,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 +957,7 @@ export function attachExecutionLogsToTool( */ export function attachSearchContentToTool( contentParts: any[], - item: { + item: SourceAttributes & { url: string; title: string; text?: string; @@ -982,7 +986,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 +1017,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; @@ -2154,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 => { @@ -2216,12 +2221,19 @@ 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 && + !consumedImageMetadata.has(source) + ); + if (metadata) consumedImageMetadata.add(metadata); 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 +2814,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }); } attachSearchContentToTool( @@ -2822,6 +2838,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }, chunk.tool_call_id ); @@ -3129,6 +3149,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }); } attachSearchContentToTool( @@ -3149,6 +3173,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { isImage, imageKey: result.image_key, retrievalHighlightTerms, + ...getSourceAttributes( + result.search_type, + result.score_details + ), }, chunk.tool_call_id ); @@ -3200,13 +3228,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 +3303,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 +3323,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..5c14ae8547 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -1,5 +1,13 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; +import type { SourceAttributes } from "@/types/source"; +import { + buildCitationDisplayIndexMap, + withCitationDisplayIndices, +} from "@/lib/citationDisplay"; + import { useCallback, useEffect, @@ -995,6 +1003,7 @@ const ThreadView: FC = ({ open={selection !== null} selectedCitationKey={selection?.selectedCitationKey} citationContext={selection?.citationContext} + selectionToken={selection ?? undefined} onClose={onPanelClose} /> @@ -1048,6 +1057,7 @@ export const ReadOnlyConversation: FC<{ open={selection !== null} selectedCitationKey={selection?.selectedCitationKey} citationContext={selection?.citationContext} + selectionToken={selection ?? undefined} onClose={close} /> @@ -2064,7 +2074,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,9 +2333,17 @@ 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) { + const numberedSources = withCitationDisplayIndices( + displaySources, + buildCitationDisplayIndexMap(content, displaySources) + ); + for (const item of numberedSources) { if (item.isImage) { imgs.push(item); } else { diff --git a/frontend/app/[locale]/newchat/ui/markdown-text.tsx b/frontend/app/[locale]/newchat/ui/markdown-text.tsx index d29339e952..d9b82f6124 100644 --- a/frontend/app/[locale]/newchat/ui/markdown-text.tsx +++ b/frontend/app/[locale]/newchat/ui/markdown-text.tsx @@ -1,5 +1,10 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + "use client"; +import type { SourceAttributes } from "@/types/source"; +import { getSourceLabel } from "@/lib/source"; + import "@assistant-ui/react-markdown/styles/dot.css"; import { @@ -30,6 +35,10 @@ import { isLocalStorageObjectUrl, } from "@/services/storageService"; import { useSourcesPanel } from "./sources-panel-context"; +import { + buildCitationDisplayIndexMap, + withCitationDisplayIndices, +} from "@/lib/citationDisplay"; import { getCitationKey, getCitationLabel, type PanelSourceItem } from "./sources-panel"; import { searchSourcesRegistry, @@ -43,7 +52,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 +107,9 @@ function resolveCiteSources( isImage: part.isImage, imageKey: part.imageKey, retrievalHighlightTerms: part.retrievalHighlightTerms, + searchType: part.searchType, + sourceMetadata: part.sourceMetadata, + sourceReference: part.sourceReference, }]; }); @@ -132,41 +144,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 { - const displayIndexMap = new Map(); - - for (const part of content) { - if (part.type !== "text" || typeof part.text !== "string") continue; - - for (const match of part.text.matchAll(/\[\[([^\]]+)\]\]/g)) { - const citekey = match[1]; - if (!displayIndexMap.has(citekey)) { - displayIndexMap.set(citekey, displayIndexMap.size + 1); - } - } - } - - return displayIndexMap; -} - function toPanelSource(source: SearchSource): PanelSourceItem { return { sourceType: @@ -184,6 +161,9 @@ function toPanelSource(source: SearchSource): PanelSourceItem { toolSign: source.toolSign, isImage: source.isImage, retrievalHighlightTerms: source.retrievalHighlightTerms, + searchType: source.searchType, + sourceMetadata: source.sourceMetadata, + sourceReference: source.sourceReference, }; } @@ -292,15 +272,24 @@ const CiteComponent: FC< const messageSources = resolveCiteSources(messageId, content); const source = findCiteSource(messageSources, citekey); - const panelSource = source ? toPanelSource(source) : undefined; const citeIndex = getCiteIndex(citekey); const sourceIndex = source?.citeIndex ?? citeIndex ?? 0; const citationKey = source ? getCitationKey({ citeIndex: source.citeIndex, toolSign: source.toolSign }) : citekey.trim().toLowerCase(); - const citationDisplayIndexMap = buildCitationDisplayIndexMap(content); - const displayIndex = citationDisplayIndexMap.get(citekey) ?? sourceIndex; - const panelItems = messageSources.map(toPanelSource); + const citationDisplayIndexMap = buildCitationDisplayIndexMap( + content, + messageSources + ); + const displayIndex = + citationDisplayIndexMap.get(citekey.trim().toLowerCase()) ?? sourceIndex; + const panelItems = withCitationDisplayIndices( + messageSources.map(toPanelSource), + citationDisplayIndexMap + ); + const panelSource = source + ? panelItems.find((item) => getCitationKey(item) === citationKey) + : undefined; const sources = panelItems.filter((item) => !item.isImage); const images = panelItems.filter((item) => item.isImage); @@ -309,7 +298,7 @@ const CiteComponent: FC< citekey={citekey} displayIndex={displayIndex} sourceIndex={sourceIndex} - label={source ? getCitationLabel(toPanelSource(source), { + label={panelSource ? getCitationLabel(panelSource, { knowledgeBase: t("chat.sources.knowledgeBase"), web: t("chat.sources.web"), source: t("chat.sources.source"), @@ -319,7 +308,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 @@ -360,8 +354,11 @@ const MarkdownTextImpl = () => { ); }); const citationDisplayIndexMap = useMemo( - () => buildCitationDisplayIndexMap(content), - [content] + () => buildCitationDisplayIndexMap( + content, + resolveCiteSources(messageId, content) + ), + [content, messageId] ); const citationIndexMapAttribute = useMemo( () => JSON.stringify(Object.fromEntries(citationDisplayIndexMap)), diff --git a/frontend/app/[locale]/newchat/ui/sources-panel.tsx b/frontend/app/[locale]/newchat/ui/sources-panel.tsx index 2786e5d847..02bf9068dd 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 { @@ -15,11 +17,11 @@ import { FileTextIcon, ImageIcon, LoaderCircleIcon, - ServerIcon, XIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { getCitationKey } from "@/lib/citationDisplay"; import { CiteIndexBadge, HighlightedChunkText, @@ -37,13 +39,19 @@ import { storageService, } from "@/services/storageService"; import { AuthenticatedImage } from "./authenticated-image"; +import type { SourceAttributes } from "@/types/source"; +import { getSourceLabel, getSourceProvider, isAidpSource } from "@/lib/source"; +import { SourceMetadata } from "@/components/common/sourceMetadata"; +import log from "@/lib/logger"; + +export { getCitationKey } from "@/lib/citationDisplay"; /** * 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; @@ -54,22 +62,17 @@ export interface PanelSourceItem { objectName?: string; isImage?: boolean; citeIndex?: number; + /** Message-wide number shared with inline citations, including image hits. */ + displayIndex?: number; toolSign?: string; retrievalHighlightTerms?: string[]; } -export function getCitationKey(item: Pick): string | undefined { - if (!Number.isFinite(item.citeIndex)) return undefined; - const index = item.citeIndex as number; - const toolSign = item.toolSign?.trim().toLowerCase(); - return toolSign ? `${toolSign}${index}` : String(index); -} - export function getCitationLabel( - item: Pick, + item: Pick, labels: { knowledgeBase: string; web: string; source: string }, ): string { - const index = item.citeIndex ?? 0; + const index = item.displayIndex ?? item.citeIndex ?? 0; if (item.toolSign === "a" || item.sourceType === "document") { return `${labels.knowledgeBase} ${index}`; } @@ -88,6 +91,8 @@ export interface SourcesPanelProps { open: boolean; selectedCitationKey?: string; citationContext?: string; + /** Changes for every citation click, including repeated clicks on one marker. */ + selectionToken?: object; className?: string; onClose: () => void; } @@ -105,6 +110,7 @@ export const SourcesPanel: FC = ({ open, selectedCitationKey, citationContext, + selectionToken, className, onClose, }) => { @@ -126,12 +132,21 @@ export const SourcesPanel: FC = ({ } else if (images.length > 0) { setActiveTab("images"); } - }, [open, selectedCitationKey, sources.length, images.length]); + }, [ + open, + selectedCitationKey, + sources.length, + images.length, + selectionToken, + ]); if (!open) return null; 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 (