open-webui/src/lib/components/chat/Messages/Citations.svelte
Perry Li 9cc9df3018 fix(chat): resolve duplicate collapsible IDs causing citation modal failures
Fix an issue where clicking inline citations in subsequent chat messages failed to open the citation modal when multiple collapsible sections are present.

The root cause was duplicate "collapsible-sources" IDs assigned to all Collapsible components. This led document.getElementById() to always return the first instance, preventing subsequent messages from opening their CitationModal.

Changes:
- Modify Collapsible ID generation in Citations.svelte to use unique IDs with "collapsible-${message.id}" pattern
- Update ResponseMessage.svelte's onSourceClick handler to reference the dynamic collapsible IDs
- Ensure proper citation modal binding for each chat message's sources

Affected components:
- Collapsible (expandable content sections)
- CitationsModal (citation detail popup)

This ensures each chat message's sources are independently collapsible and maintains proper citation modal binding throughout message history.
2025-03-06 10:10:53 +00:00

206 lines
6.2 KiB
Svelte

<script lang="ts">
import { getContext } from 'svelte';
import CitationsModal from './CitationsModal.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
const i18n = getContext('i18n');
export let id = '';
export let sources = [];
let citations = [];
let showPercentage = false;
let showRelevance = true;
let showCitationModal = false;
let selectedCitation: any = null;
let isCollapsibleOpen = false;
function calculateShowRelevance(sources: any[]) {
const distances = sources.flatMap((citation) => citation.distances ?? []);
const inRange = distances.filter((d) => d !== undefined && d >= -1 && d <= 1).length;
const outOfRange = distances.filter((d) => d !== undefined && (d < -1 || d > 1)).length;
if (distances.length === 0) {
return false;
}
if (
(inRange === distances.length - 1 && outOfRange === 1) ||
(outOfRange === distances.length - 1 && inRange === 1)
) {
return false;
}
return true;
}
function shouldShowPercentage(sources: any[]) {
const distances = sources.flatMap((citation) => citation.distances ?? []);
return distances.every((d) => d !== undefined && d >= -1 && d <= 1);
}
$: {
console.log('sources', sources);
citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) {
return acc;
}
source.document.forEach((document, index) => {
const metadata = source.metadata?.[index];
const distance = source.distances?.[index];
// Within the same citation there could be multiple documents
const id = metadata?.source ?? source?.source?.id ?? 'N/A';
let _source = source?.source;
if (metadata?.name) {
_source = { ..._source, name: metadata.name };
}
if (id.startsWith('http://') || id.startsWith('https://')) {
_source = { ..._source, name: id, url: id };
}
const existingSource = acc.find((item) => item.id === id);
if (existingSource) {
existingSource.document.push(document);
existingSource.metadata.push(metadata);
if (distance !== undefined) existingSource.distances.push(distance);
} else {
acc.push({
id: id,
source: _source,
document: [document],
metadata: metadata ? [metadata] : [],
distances: distance !== undefined ? [distance] : undefined
});
}
});
return acc;
}, []);
showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations);
}
</script>
<CitationsModal
bind:show={showCitationModal}
citation={selectedCitation}
{showPercentage}
{showRelevance}
/>
{#if citations.length > 0}
<div class=" py-0.5 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
{#if citations.length <= 3}
<div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx}
<button
id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div
class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
>
{citation.source.name}
</div>
</button>
{/each}
</div>
{:else}
<Collapsible
id={`collapsible-${id}`}
bind:open={isCollapsibleOpen}
className="w-full max-w-full "
buttonClassName="w-fit max-w-full"
>
<div
class="flex w-full overflow-auto items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div
class="flex-1 flex items-center gap-1 overflow-auto scrollbar-none w-full max-w-full"
>
<span class="whitespace-nowrap hidden sm:inline shrink-0"
>{$i18n.t('References from')}</span
>
<div class="flex items-center overflow-auto scrollbar-none w-full max-w-full flex-1">
<div class="flex text-xs font-medium items-center">
{#each citations.slice(0, 2) as citation, idx}
<button
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{citation.source.name}
</div>
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-1 whitespace-nowrap shrink-0">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
{citations.length - 2}
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
<div slot="content">
<div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx}
<button
id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{citation.source.name}
</div>
</button>
{/each}
</div>
</div>
</Collapsible>
{/if}
</div>
{/if}