mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
refac: sources and citations
This commit is contained in:
parent
b0491886bc
commit
ec45d77ce9
11 changed files with 201 additions and 62 deletions
|
|
@ -169,7 +169,7 @@
|
||||||
></iframe>
|
></iframe>
|
||||||
{:else}
|
{:else}
|
||||||
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
|
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
|
||||||
{document.document}
|
{document.document.trim()}
|
||||||
</pre>
|
</pre>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
import { mentionExtension } from '$lib/utils/marked/mention-extension';
|
import { mentionExtension } from '$lib/utils/marked/mention-extension';
|
||||||
|
|
||||||
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
|
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
|
||||||
|
import footnoteExtension from '$lib/utils/marked/footnote-extension';
|
||||||
|
import citationExtension from '$lib/utils/marked/citation-extension';
|
||||||
|
|
||||||
export let id = '';
|
export let id = '';
|
||||||
export let content;
|
export let content;
|
||||||
|
|
@ -39,6 +41,8 @@
|
||||||
|
|
||||||
marked.use(markedKatexExtension(options));
|
marked.use(markedKatexExtension(options));
|
||||||
marked.use(markedExtension(options));
|
marked.use(markedExtension(options));
|
||||||
|
marked.use(citationExtension(options));
|
||||||
|
marked.use(footnoteExtension(options));
|
||||||
marked.use(disableSingleTilde);
|
marked.use(disableSingleTilde);
|
||||||
marked.use({
|
marked.use({
|
||||||
extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })]
|
extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })]
|
||||||
|
|
@ -47,7 +51,7 @@
|
||||||
$: (async () => {
|
$: (async () => {
|
||||||
if (content) {
|
if (content) {
|
||||||
tokens = marked.lexer(
|
tokens = marked.lexer(
|
||||||
replaceTokens(processResponseContent(content), sourceIds, model?.name, $user?.name)
|
replaceTokens(processResponseContent(content), model?.name, $user?.name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -61,6 +65,7 @@
|
||||||
{save}
|
{save}
|
||||||
{preview}
|
{preview}
|
||||||
{editCodeBlock}
|
{editCodeBlock}
|
||||||
|
{sourceIds}
|
||||||
{topPadding}
|
{topPadding}
|
||||||
{onTaskClick}
|
{onTaskClick}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@
|
||||||
import type { Token } from 'marked';
|
import type { Token } from 'marked';
|
||||||
|
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
import Source from './Source.svelte';
|
|
||||||
import { settings } from '$lib/stores';
|
import { settings } from '$lib/stores';
|
||||||
|
|
||||||
export let id: string;
|
export let id: string;
|
||||||
export let token: Token;
|
export let token: Token;
|
||||||
|
|
||||||
export let onSourceClick: Function = () => {};
|
|
||||||
|
|
||||||
let html: string | null = null;
|
let html: string | null = null;
|
||||||
|
|
||||||
$: if (token.type === 'html' && token?.text) {
|
$: if (token.type === 'html' && token?.text) {
|
||||||
|
|
@ -129,8 +126,6 @@
|
||||||
}}
|
}}
|
||||||
></iframe>
|
></iframe>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if token.text.includes(`<source_id`)}
|
|
||||||
<Source {id} {token} onClick={onSourceClick} />
|
|
||||||
{:else if token.text.trim().match(/^<br\s*\/?>$/i)}
|
{:else if token.text.trim().match(/^<br\s*\/?>$/i)}
|
||||||
<br />
|
<br />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@
|
||||||
import TextToken from './MarkdownInlineTokens/TextToken.svelte';
|
import TextToken from './MarkdownInlineTokens/TextToken.svelte';
|
||||||
import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte';
|
import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte';
|
||||||
import MentionToken from './MarkdownInlineTokens/MentionToken.svelte';
|
import MentionToken from './MarkdownInlineTokens/MentionToken.svelte';
|
||||||
|
import SourceToken from './SourceToken.svelte';
|
||||||
|
|
||||||
export let id: string;
|
export let id: string;
|
||||||
export let done = true;
|
export let done = true;
|
||||||
export let tokens: Token[];
|
export let tokens: Token[];
|
||||||
|
export let sourceIds = [];
|
||||||
export let onSourceClick: Function = () => {};
|
export let onSourceClick: Function = () => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -68,6 +70,17 @@
|
||||||
></iframe>
|
></iframe>
|
||||||
{:else if token.type === 'mention'}
|
{:else if token.type === 'mention'}
|
||||||
<MentionToken {token} />
|
<MentionToken {token} />
|
||||||
|
{:else if token.type === 'footnote'}
|
||||||
|
{@html DOMPurify.sanitize(
|
||||||
|
`<sup class="footnote-ref footnote-ref-text">${token.escapedText}</sup>`
|
||||||
|
) || ''}
|
||||||
|
{:else if token.type === 'citation'}
|
||||||
|
<SourceToken {id} {token} {sourceIds} onClick={onSourceClick} />
|
||||||
|
<!-- {#if token.ids && token.ids.length > 0}
|
||||||
|
{#each token.ids as sourceId}
|
||||||
|
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} onClick={onSourceClick} />
|
||||||
|
{/each}
|
||||||
|
{/if} -->
|
||||||
{:else if token.type === 'text'}
|
{:else if token.type === 'text'}
|
||||||
<TextToken {token} {done} />
|
<TextToken {token} {done} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Download from '$lib/components/icons/Download.svelte';
|
import Download from '$lib/components/icons/Download.svelte';
|
||||||
|
|
||||||
import Source from './Source.svelte';
|
|
||||||
import HtmlToken from './HTMLToken.svelte';
|
import HtmlToken from './HTMLToken.svelte';
|
||||||
import Clipboard from '$lib/components/icons/Clipboard.svelte';
|
import Clipboard from '$lib/components/icons/Clipboard.svelte';
|
||||||
|
|
||||||
|
|
@ -29,6 +28,7 @@
|
||||||
export let tokens: Token[];
|
export let tokens: Token[];
|
||||||
export let top = true;
|
export let top = true;
|
||||||
export let attributes = {};
|
export let attributes = {};
|
||||||
|
export let sourceIds = [];
|
||||||
|
|
||||||
export let done = true;
|
export let done = true;
|
||||||
|
|
||||||
|
|
@ -96,6 +96,7 @@
|
||||||
id={`${id}-${tokenIdx}-h`}
|
id={`${id}-${tokenIdx}-h`}
|
||||||
tokens={token.tokens}
|
tokens={token.tokens}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|
@ -147,6 +148,7 @@
|
||||||
id={`${id}-${tokenIdx}-header-${headerIdx}`}
|
id={`${id}-${tokenIdx}-header-${headerIdx}`}
|
||||||
tokens={header.tokens}
|
tokens={header.tokens}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,6 +174,7 @@
|
||||||
id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
|
id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
|
||||||
tokens={cell.tokens}
|
tokens={cell.tokens}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -348,6 +351,7 @@
|
||||||
id={`${id}-${tokenIdx}-p`}
|
id={`${id}-${tokenIdx}-p`}
|
||||||
tokens={token.tokens ?? []}
|
tokens={token.tokens ?? []}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -359,6 +363,7 @@
|
||||||
id={`${id}-${tokenIdx}-t`}
|
id={`${id}-${tokenIdx}-t`}
|
||||||
tokens={token.tokens}
|
tokens={token.tokens}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -370,6 +375,7 @@
|
||||||
id={`${id}-${tokenIdx}-p`}
|
id={`${id}-${tokenIdx}-p`}
|
||||||
tokens={token.tokens ?? []}
|
tokens={token.tokens ?? []}
|
||||||
{done}
|
{done}
|
||||||
|
{sourceIds}
|
||||||
{onSourceClick}
|
{onSourceClick}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let id;
|
export let id;
|
||||||
export let token;
|
|
||||||
|
export let title: string = 'N/A';
|
||||||
|
|
||||||
export let onClick: Function = () => {};
|
export let onClick: Function = () => {};
|
||||||
|
|
||||||
let attributes: Record<string, string | undefined> = {};
|
|
||||||
|
|
||||||
function extractAttributes(input: string): Record<string, string> {
|
|
||||||
const regex = /(\w+)="([^"]*)"/g;
|
|
||||||
let match;
|
|
||||||
let attrs: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Loop through all matches and populate the attributes object
|
|
||||||
while ((match = regex.exec(input)) !== null) {
|
|
||||||
attrs[match[1]] = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
return attrs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to return only the domain from a URL
|
// Helper function to return only the domain from a URL
|
||||||
function getDomain(url: string): string {
|
function getDomain(url: string): string {
|
||||||
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
|
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
|
||||||
|
|
@ -44,23 +31,17 @@
|
||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
};
|
};
|
||||||
|
|
||||||
$: attributes = extractAttributes(token.text);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if attributes.title !== 'N/A'}
|
{#if title !== 'N/A'}
|
||||||
<button
|
<button
|
||||||
class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg"
|
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
onClick(id, attributes.data);
|
onClick(id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="line-clamp-1">
|
<span class="line-clamp-1">
|
||||||
{getDisplayTitle(
|
{getDisplayTitle(formattedTitle(decodeURIComponent(title)))}
|
||||||
decodeURIComponent(attributes.title)
|
|
||||||
? formattedTitle(decodeURIComponent(attributes.title))
|
|
||||||
: ''
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
70
src/lib/components/chat/Messages/Markdown/SourceToken.svelte
Normal file
70
src/lib/components/chat/Messages/Markdown/SourceToken.svelte
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { LinkPreview } from 'bits-ui';
|
||||||
|
import Source from './Source.svelte';
|
||||||
|
|
||||||
|
export let id;
|
||||||
|
export let token;
|
||||||
|
export let sourceIds = [];
|
||||||
|
export let onClick: Function = () => {};
|
||||||
|
|
||||||
|
let containerElement;
|
||||||
|
|
||||||
|
// Helper function to return only the domain from a URL
|
||||||
|
function getDomain(url: string): string {
|
||||||
|
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
|
||||||
|
|
||||||
|
if (domain.startsWith('www.')) {
|
||||||
|
return domain.slice(4);
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if text is a URL and return the domain
|
||||||
|
function formattedTitle(title: string): string {
|
||||||
|
if (title.startsWith('http')) {
|
||||||
|
return getDomain(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayTitle = (title: string) => {
|
||||||
|
if (!title) return 'N/A';
|
||||||
|
if (title.length > 30) {
|
||||||
|
return title.slice(0, 15) + '...' + title.slice(-10);
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if (token?.ids ?? []).length == 1}
|
||||||
|
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
|
||||||
|
{:else}
|
||||||
|
<LinkPreview.Root openDelay={0}>
|
||||||
|
<LinkPreview.Trigger>
|
||||||
|
<button
|
||||||
|
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
|
||||||
|
>
|
||||||
|
<span class="line-clamp-1">
|
||||||
|
{getDisplayTitle(formattedTitle(decodeURIComponent(sourceIds[token.ids[0] - 1])))}
|
||||||
|
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</LinkPreview.Trigger>
|
||||||
|
<LinkPreview.Content
|
||||||
|
class="z-[999]"
|
||||||
|
align="start"
|
||||||
|
strategy="fixed"
|
||||||
|
sideOffset={6}
|
||||||
|
el={containerElement}
|
||||||
|
>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
|
||||||
|
{#each token.ids as sourceId}
|
||||||
|
<div class="">
|
||||||
|
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} {onClick} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</LinkPreview.Content>
|
||||||
|
</LinkPreview.Root>
|
||||||
|
{/if}
|
||||||
|
|
@ -797,11 +797,11 @@
|
||||||
onTaskClick={async (e) => {
|
onTaskClick={async (e) => {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}}
|
}}
|
||||||
onSourceClick={async (id, idx) => {
|
onSourceClick={async (id) => {
|
||||||
console.log(id, idx);
|
console.log(id);
|
||||||
|
|
||||||
if (citationsElement) {
|
if (citationsElement) {
|
||||||
citationsElement?.showSourceModal(idx - 1);
|
citationsElement?.showSourceModal(id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onAddMessages={({ modelId, parentId, messages }) => {
|
onAddMessages={({ modelId, parentId, messages }) => {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function escapeRegExp(string: string): string {
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const replaceTokens = (content, sourceIds, char, user) => {
|
export const replaceTokens = (content, char, user) => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
{ regex: /{{char}}/gi, replacement: char },
|
{ regex: /{{char}}/gi, replacement: char },
|
||||||
{ regex: /{{user}}/gi, replacement: user },
|
{ regex: /{{user}}/gi, replacement: user },
|
||||||
|
|
@ -67,30 +67,6 @@ export const replaceTokens = (content, sourceIds, char, user) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(sourceIds)) {
|
|
||||||
// Match both [1], [2], and [1,2,3] forms
|
|
||||||
const multiRefRegex = /\[([\d,\s]+)\]/g;
|
|
||||||
segment = segment.replace(multiRefRegex, (match, group) => {
|
|
||||||
// Extract numbers like 1,2,3
|
|
||||||
const indices = group
|
|
||||||
.split(',')
|
|
||||||
.map((n) => parseInt(n.trim(), 10))
|
|
||||||
.filter((n) => !isNaN(n));
|
|
||||||
|
|
||||||
// Replace each index with a <source_id> tag
|
|
||||||
const sources = indices
|
|
||||||
.map((idx) => {
|
|
||||||
const sourceId = sourceIds[idx - 1];
|
|
||||||
return sourceId
|
|
||||||
? `<source_id data="${idx}" title="${encodeURIComponent(sourceId)}" />`
|
|
||||||
: `[${idx}]`;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
return sources;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return segment;
|
return segment;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
55
src/lib/utils/marked/citation-extension.ts
Normal file
55
src/lib/utils/marked/citation-extension.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
export function citationExtension() {
|
||||||
|
return {
|
||||||
|
name: 'citation',
|
||||||
|
level: 'inline' as const,
|
||||||
|
|
||||||
|
start(src: string) {
|
||||||
|
// Trigger on any [number]
|
||||||
|
return src.search(/\[(\d[\d,\s]*)\]/);
|
||||||
|
},
|
||||||
|
|
||||||
|
tokenizer(src: string) {
|
||||||
|
// Avoid matching footnotes
|
||||||
|
if (/^\[\^/.test(src)) return;
|
||||||
|
|
||||||
|
// Match ONE OR MORE adjacent [1] or [1,2] blocks
|
||||||
|
// Example matched: "[1][2,3][4]"
|
||||||
|
const rule = /^(\[(?:\d[\d,\s]*)\])+/;
|
||||||
|
const match = rule.exec(src);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
const raw = match[0];
|
||||||
|
|
||||||
|
// Extract ALL bracket groups inside the big match
|
||||||
|
const groupRegex = /\[([\d,\s]+)\]/g;
|
||||||
|
const ids: number[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = groupRegex.exec(raw))) {
|
||||||
|
const parsed = m[1]
|
||||||
|
.split(',')
|
||||||
|
.map((n) => parseInt(n.trim(), 10))
|
||||||
|
.filter((n) => !isNaN(n));
|
||||||
|
|
||||||
|
ids.push(...parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'citation',
|
||||||
|
raw,
|
||||||
|
ids // merged list
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer(token: any) {
|
||||||
|
// e.g. "1,2,3"
|
||||||
|
return token.ids.join(',');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return {
|
||||||
|
extensions: [citationExtension()]
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/lib/utils/marked/footnote-extension.ts
Normal file
38
src/lib/utils/marked/footnote-extension.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// footnote-extension.ts
|
||||||
|
// Simple extension for marked to support footnote references like [^1], [^note]
|
||||||
|
|
||||||
|
function escapeHtml(s: string) {
|
||||||
|
return s.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function footnoteExtension() {
|
||||||
|
return {
|
||||||
|
name: 'footnote',
|
||||||
|
level: 'inline' as const,
|
||||||
|
start(src: string) {
|
||||||
|
return src.search(/\[\^\s*[a-zA-Z0-9_-]+\s*\]/);
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const rule = /^\[\^\s*([a-zA-Z0-9_-]+)\s*\]/;
|
||||||
|
const match = rule.exec(src);
|
||||||
|
if (match) {
|
||||||
|
const escapedText = escapeHtml(match[1]);
|
||||||
|
return {
|
||||||
|
type: 'footnote',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
escapedText: escapedText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return {
|
||||||
|
extensions: [footnoteExtension()]
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue