Files
Enterprise-Knowledge-Base/frontend/src/utils/referenceSources.ts
T
wizardchen 3209ed77fd feat(quick-qa): add references drawer and polish retrieval source UI
Add a side panel for web and knowledge-base citations with citation-badge
highlighting, refine collapsed RAG pipeline summary layout, and emit
knowledge_base_id from agent knowledge_search results.
2026-07-08 00:26:34 +08:00

313 lines
9.1 KiB
TypeScript

export type ReferenceItemKind = 'web' | 'document' | 'tool'
export type KnowledgeReferenceLike = {
id?: string
chunk_ids?: string[]
knowledge_id?: string
knowledge_title?: string
knowledge_filename?: string
knowledge_base_id?: string
chunk_index?: number
chunk_type?: string
content?: string
metadata?: Record<string, string>
}
export type ReferenceListItem = {
key: string
kind: ReferenceItemKind
index: number
title: string
url?: string
domain?: string
faviconUrl?: string
snippet?: string
chunkId?: string
chunkIds?: string[]
knowledgeId?: string
knowledgeBaseId?: string
content?: string
}
export type ReferenceDrawerSection = {
id: 'web' | 'documents' | 'tools'
items: ReferenceListItem[]
}
export function normalizeReferenceUrl(url: string): string {
const raw = String(url || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
parsed.hash = ''
let pathname = parsed.pathname
if (pathname.length > 1 && pathname.endsWith('/')) {
pathname = pathname.slice(0, -1)
}
parsed.pathname = pathname
return parsed.toString()
} catch {
return raw.replace(/\/$/, '')
}
}
export function getWebSearchUrl(item: KnowledgeReferenceLike): string {
if (item.metadata?.url) return item.metadata.url
if (item.id && (item.id.startsWith('http://') || item.id.startsWith('https://'))) {
return item.id
}
return ''
}
export function getDomainFromUrl(url: string): string {
if (!url) return ''
try {
return new URL(url).hostname.replace(/^www\./i, '')
} catch {
return url
}
}
export function getFaviconUrl(urlOrDomain: string): string {
const domain = urlOrDomain.includes('://')
? getDomainFromUrl(urlOrDomain)
: urlOrDomain.replace(/^www\./i, '')
if (!domain) return ''
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`
}
function truncateText(text: string, maxLen: number): string {
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLen) return normalized
return `${normalized.slice(0, maxLen)}…`
}
function isWebReference(item: KnowledgeReferenceLike): boolean {
return item.chunk_type === 'web_search'
}
function isToolReference(item: KnowledgeReferenceLike): boolean {
return item.chunk_type === 'tool_result'
}
function buildWebItem(item: KnowledgeReferenceLike, index: number): ReferenceListItem | null {
const url = getWebSearchUrl(item)
if (!url) return null
const title =
item.knowledge_title ||
item.metadata?.title ||
getDomainFromUrl(url) ||
url
const snippet =
item.metadata?.snippet ||
truncateText(item.content || '', 220)
const domain = getDomainFromUrl(url)
const normalizedUrl = normalizeReferenceUrl(url)
return {
key: `web:${normalizedUrl}`,
kind: 'web',
index,
title,
url,
domain,
faviconUrl: getFaviconUrl(url),
snippet: snippet || undefined,
chunkId: item.id,
content: item.content,
}
}
function buildDocumentItem(item: KnowledgeReferenceLike, index: number): ReferenceListItem {
const chunkId = item.id || `${item.knowledge_id || 'doc'}-${item.chunk_index ?? index}`
const title = item.knowledge_title || item.knowledge_filename || item.knowledge_id || 'Document'
const documentKey =
item.knowledge_id ||
[item.knowledge_base_id, item.knowledge_title || item.knowledge_filename].filter(Boolean).join(':') ||
chunkId
return {
key: `doc:${documentKey}`,
kind: 'document',
index,
title,
chunkId,
chunkIds: item.chunk_ids,
knowledgeId: item.knowledge_id,
knowledgeBaseId: item.knowledge_base_id,
snippet: truncateText(item.content || '', 220) || undefined,
content: item.content,
}
}
function buildToolItem(item: KnowledgeReferenceLike, index: number): ReferenceListItem {
const id = item.id || `tool-${index}`
return {
key: `tool:${id}`,
kind: 'tool',
index,
title: item.knowledge_title || item.metadata?.title || 'Tool result',
domain: item.metadata?.source || item.metadata?.tool || undefined,
snippet: truncateText(item.content || '', 220) || undefined,
chunkId: id,
content: item.content,
}
}
function getDocumentGroupKey(item: KnowledgeReferenceLike, index: number): string {
if (item.knowledge_id) return item.knowledge_id
const title = item.knowledge_title || item.knowledge_filename
if (title) return [item.knowledge_base_id, title].filter(Boolean).join(':')
return (
item.id ||
`doc-${index}`
)
}
function mergeDocumentReferences(refs: KnowledgeReferenceLike[]): KnowledgeReferenceLike[] {
const groups = new Map<string, KnowledgeReferenceLike & { content_parts?: string[] }>()
refs.forEach((item, index) => {
const key = getDocumentGroupKey(item, index)
const content = String(item.content || '').trim()
const chunkIds = Array.from(new Set([...(item.chunk_ids || []), ...(item.id ? [item.id] : [])]))
const existing = groups.get(key)
if (!existing) {
groups.set(key, {
...item,
id: item.id || key,
content_parts: content ? [content] : [],
chunk_ids: chunkIds,
})
return
}
if (!existing.knowledge_id && item.knowledge_id) existing.knowledge_id = item.knowledge_id
if (!existing.knowledge_title && item.knowledge_title) existing.knowledge_title = item.knowledge_title
if (!existing.knowledge_filename && item.knowledge_filename) existing.knowledge_filename = item.knowledge_filename
if (!existing.knowledge_base_id && item.knowledge_base_id) existing.knowledge_base_id = item.knowledge_base_id
for (const chunkId of chunkIds) {
if (!existing.chunk_ids?.includes(chunkId)) {
existing.chunk_ids = [...(existing.chunk_ids || []), chunkId]
}
}
if (content && !existing.content_parts?.includes(content)) {
existing.content_parts = [...(existing.content_parts || []), content]
}
})
return Array.from(groups.values()).map((item) => {
const { content_parts: contentParts, ...rest } = item
return {
...rest,
content: contentParts?.slice(0, 3).join('\n\n') || rest.content,
}
})
}
function mergeWebReferences(refs: KnowledgeReferenceLike[]): KnowledgeReferenceLike[] {
const groups = new Map<string, KnowledgeReferenceLike>()
refs.forEach((item, index) => {
const url = getWebSearchUrl(item)
const key = url ? normalizeReferenceUrl(url) : item.id || `web-${index}`
if (!groups.has(key)) {
groups.set(key, item)
}
})
return Array.from(groups.values())
}
export function buildReferenceSections(
refs: KnowledgeReferenceLike[] | null | undefined,
): ReferenceDrawerSection[] {
const list = Array.isArray(refs) ? refs.filter(Boolean) : []
if (!list.length) return []
const webReferences: KnowledgeReferenceLike[] = []
const documentReferences: KnowledgeReferenceLike[] = []
const toolReferences: KnowledgeReferenceLike[] = []
const webItems: ReferenceListItem[] = []
const docItems: ReferenceListItem[] = []
const toolItems: ReferenceListItem[] = []
let webIndex = 0
let docIndex = 0
let toolIndex = 0
for (const item of list) {
if (isWebReference(item)) {
webReferences.push(item)
continue
}
if (isToolReference(item)) {
toolReferences.push(item)
continue
}
documentReferences.push(item)
}
for (const item of mergeWebReferences(webReferences)) {
const built = buildWebItem(item, ++webIndex)
if (built) webItems.push(built)
}
for (const item of mergeDocumentReferences(documentReferences)) {
docItems.push(buildDocumentItem(item, ++docIndex))
}
for (const item of toolReferences) {
toolItems.push(buildToolItem(item, ++toolIndex))
}
const sections: ReferenceDrawerSection[] = []
if (webItems.length) sections.push({ id: 'web', items: webItems })
if (docItems.length) sections.push({ id: 'documents', items: docItems })
if (toolItems.length) sections.push({ id: 'tools', items: toolItems })
return sections
}
export function buildReferenceList(
refs: KnowledgeReferenceLike[] | null | undefined,
): ReferenceListItem[] {
return buildReferenceSections(refs).flatMap((section) => section.items)
}
export type ReferenceHighlightTarget = {
url?: string
chunkId?: string
key?: string
}
export function resolveReferenceHighlightKey(
refs: KnowledgeReferenceLike[] | null | undefined,
target: ReferenceHighlightTarget | null | undefined,
): string | null {
if (!target) return null
if (target.key) return target.key
const items = buildReferenceList(refs)
if (!items.length) return null
if (target.url) {
const normalized = normalizeReferenceUrl(target.url)
const hit = items.find(
(item) => item.kind === 'web' && item.url && normalizeReferenceUrl(item.url) === normalized,
)
if (hit) return hit.key
}
if (target.chunkId) {
const raw = String(target.chunkId).trim()
const hit = items.find(
(item) =>
item.chunkId === raw ||
item.chunkIds?.includes(raw) ||
item.key === `doc:${raw}` ||
(item.kind === 'web' && (item.chunkId === raw || item.url === raw)),
)
if (hit) return hit.key
}
return null
}