webui: Improve copy to clipboard with text attachments (#17969)
* feat: Create copy/paste user message including "pasted text" attachments * chore: update webui build output * chore: update webui static output * fix: UI issues * chore: update webui static output * fix: Decode HTML entities using `DOMParser` * chore: update webui build output * chore: update webui static output
This commit is contained in:
parent
a20979d433
commit
3034836d36
Binary file not shown.
|
|
@ -619,11 +619,12 @@ flowchart TB
|
||||||
|
|
||||||
### Test Types
|
### Test Types
|
||||||
|
|
||||||
| Type | Tool | Location | Command |
|
| Type | Tool | Location | Command |
|
||||||
| ------------- | ------------------ | -------------------------------- | ------------------- |
|
| ------------- | ------------------ | ---------------- | ------------------- |
|
||||||
| **E2E** | Playwright | `tests/e2e/` | `npm run test:e2e` |
|
| **Unit** | Vitest | `tests/unit/` | `npm run test:unit` |
|
||||||
| **Unit** | Vitest | `tests/client/`, `tests/server/` | `npm run test:unit` |
|
| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui` |
|
||||||
| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui` |
|
| **E2E** | Playwright | `tests/e2e/` | `npm run test:e2e` |
|
||||||
|
| **Client** | Vitest | `tests/client/`. | `npm run test:unit` |
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,11 @@
|
||||||
"reset": "rm -rf .svelte-kit node_modules",
|
"reset": "rm -rf .svelte-kit node_modules",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:server -- --run && npm run test:e2e",
|
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:client": "vitest --project=client",
|
"test:client": "vitest --project=client",
|
||||||
"test:server": "vitest --project=server",
|
"test:unit": "vitest --project=unit",
|
||||||
"test:ui": "vitest --project=ui",
|
"test:ui": "vitest --project=ui",
|
||||||
"test:unit": "vitest",
|
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"cleanup": "rm -rf .svelte-kit build node_modules test-results"
|
"cleanup": "rm -rf .svelte-kit build node_modules test-results"
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
|
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
|
||||||
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
|
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
|
||||||
{:else if isAudio}
|
{:else if isAudio}
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="w-full max-w-md text-center">
|
<div class="w-full max-w-md text-center">
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
MimeTypeImage,
|
MimeTypeImage,
|
||||||
MimeTypeText
|
MimeTypeText
|
||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import { isIMEComposing } from '$lib/utils';
|
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
AudioRecorder,
|
AudioRecorder,
|
||||||
convertToWav,
|
convertToWav,
|
||||||
|
|
@ -191,7 +191,6 @@
|
||||||
|
|
||||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||||
|
|
||||||
// Check if model is selected first
|
|
||||||
if (!checkModelSelected()) return;
|
if (!checkModelSelected()) return;
|
||||||
|
|
||||||
const messageToSend = message.trim();
|
const messageToSend = message.trim();
|
||||||
|
|
@ -228,6 +227,31 @@
|
||||||
|
|
||||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||||
|
|
||||||
|
if (text.startsWith('"')) {
|
||||||
|
const parsed = parseClipboardContent(text);
|
||||||
|
|
||||||
|
if (parsed.textAttachments.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
message = parsed.message;
|
||||||
|
|
||||||
|
const attachmentFiles = parsed.textAttachments.map(
|
||||||
|
(att) =>
|
||||||
|
new File([att.content], att.name, {
|
||||||
|
type: MimeTypeText.PLAIN
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onFileUpload?.(attachmentFiles);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef?.focus();
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
text.length > 0 &&
|
text.length > 0 &&
|
||||||
pasteLongTextToFileLength > 0 &&
|
pasteLongTextToFileLength > 0 &&
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { copyToClipboard, isIMEComposing } from '$lib/utils';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
|
||||||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||||
|
|
@ -87,7 +88,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await copyToClipboard(message.content, 'Message copied to clipboard');
|
const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
|
||||||
|
const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
|
||||||
|
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
|
||||||
onCopy?.(message);
|
onCopy?.(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@
|
||||||
label: 'Paste long text to file length',
|
label: 'Paste long text to file length',
|
||||||
type: 'input'
|
type: 'input'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'copyTextAttachmentsAsPlainText',
|
||||||
|
label: 'Copy text attachments as plain text',
|
||||||
|
type: 'checkbox'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'enableContinueGeneration',
|
key: 'enableContinueGeneration',
|
||||||
label: 'Enable "Continue" button',
|
label: 'Enable "Continue" button',
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,10 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
|
class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
|
||||||
style="max-height: {maxHeight};"
|
style="max-height: {maxHeight}; max-width: {maxWidth};"
|
||||||
>
|
>
|
||||||
<pre class="m-0 overflow-x-auto p-4 max-w-[{maxWidth}]"><code class="hljs text-sm leading-relaxed"
|
<!-- Needs to be formatted as single line for proper rendering -->
|
||||||
|
<pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
|
||||||
>{@html highlightedHtml}</code
|
>{@html highlightedHtml}</code
|
||||||
></pre>
|
></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
||||||
showMessageStats: true,
|
showMessageStats: true,
|
||||||
askForTitleConfirmation: false,
|
askForTitleConfirmation: false,
|
||||||
pasteLongTextToFileLen: 2500,
|
pasteLongTextToFileLen: 2500,
|
||||||
|
copyTextAttachmentsAsPlainText: false,
|
||||||
pdfAsImage: false,
|
pdfAsImage: false,
|
||||||
disableAutoScroll: false,
|
disableAutoScroll: false,
|
||||||
renderUserContentAsMarkdown: false,
|
renderUserContentAsMarkdown: false,
|
||||||
|
|
@ -52,6 +53,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
||||||
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||||
pasteLongTextToFileLen:
|
pasteLongTextToFileLen:
|
||||||
'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
||||||
|
copyTextAttachmentsAsPlainText:
|
||||||
|
'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||||
samplers:
|
samplers:
|
||||||
'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
|
'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
|
||||||
temperature:
|
temperature:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { AttachmentType } from '$lib/enums';
|
||||||
|
import type {
|
||||||
|
DatabaseMessageExtra,
|
||||||
|
DatabaseMessageExtraTextFile,
|
||||||
|
DatabaseMessageExtraLegacyContext
|
||||||
|
} from '$lib/types/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard with toast notification
|
||||||
|
* Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
|
||||||
|
* @param text - Text to copy to clipboard
|
||||||
|
* @param successMessage - Custom success message (optional)
|
||||||
|
* @param errorMessage - Custom error message (optional)
|
||||||
|
* @returns Promise<boolean> - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(
|
||||||
|
text: string,
|
||||||
|
successMessage = 'Copied to clipboard',
|
||||||
|
errorMessage = 'Failed to copy to clipboard'
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first (secure contexts only)
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(successMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-secure contexts
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
toast.success(successMessage);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error('execCommand failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy code with HTML entity decoding and toast notification
|
||||||
|
* @param rawCode - Raw code string that may contain HTML entities
|
||||||
|
* @param successMessage - Custom success message (optional)
|
||||||
|
* @param errorMessage - Custom error message (optional)
|
||||||
|
* @returns Promise<boolean> - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
export async function copyCodeToClipboard(
|
||||||
|
rawCode: string,
|
||||||
|
successMessage = 'Code copied to clipboard',
|
||||||
|
errorMessage = 'Failed to copy code'
|
||||||
|
): Promise<boolean> {
|
||||||
|
const doc = new DOMParser().parseFromString(rawCode, 'text/html');
|
||||||
|
const decodedCode = doc.body.textContent ?? rawCode;
|
||||||
|
|
||||||
|
return copyToClipboard(decodedCode, successMessage, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format for text attachments when copied to clipboard
|
||||||
|
*/
|
||||||
|
export interface ClipboardTextAttachment {
|
||||||
|
type: typeof AttachmentType.TEXT;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed result from clipboard content
|
||||||
|
*/
|
||||||
|
export interface ParsedClipboardContent {
|
||||||
|
message: string;
|
||||||
|
textAttachments: ClipboardTextAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a message with text attachments for clipboard copying.
|
||||||
|
*
|
||||||
|
* Default format (asPlainText = false):
|
||||||
|
* ```
|
||||||
|
* "Text message content"
|
||||||
|
* [
|
||||||
|
* {"type":"TEXT","name":"filename.txt","content":"..."},
|
||||||
|
* {"type":"TEXT","name":"another.txt","content":"..."}
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Plain text format (asPlainText = true):
|
||||||
|
* ```
|
||||||
|
* Text message content
|
||||||
|
*
|
||||||
|
* file content here
|
||||||
|
*
|
||||||
|
* another file content
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param content - The message text content
|
||||||
|
* @param extras - Optional array of message attachments
|
||||||
|
* @param asPlainText - If true, format as plain text without JSON structure
|
||||||
|
* @returns Formatted string for clipboard
|
||||||
|
*/
|
||||||
|
export function formatMessageForClipboard(
|
||||||
|
content: string,
|
||||||
|
extras?: DatabaseMessageExtra[],
|
||||||
|
asPlainText: boolean = false
|
||||||
|
): string {
|
||||||
|
// Filter only text attachments (TEXT type and legacy CONTEXT type)
|
||||||
|
const textAttachments =
|
||||||
|
extras?.filter(
|
||||||
|
(extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
|
||||||
|
extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
if (textAttachments.length === 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asPlainText) {
|
||||||
|
const parts = [content];
|
||||||
|
for (const att of textAttachments) {
|
||||||
|
parts.push(att.content);
|
||||||
|
}
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
|
||||||
|
type: AttachmentType.TEXT,
|
||||||
|
name: att.name,
|
||||||
|
content: att.content
|
||||||
|
}));
|
||||||
|
|
||||||
|
return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses clipboard content to extract message and text attachments.
|
||||||
|
* Supports both plain text and the special format with attachments.
|
||||||
|
*
|
||||||
|
* @param clipboardText - Raw text from clipboard
|
||||||
|
* @returns Parsed content with message and attachments
|
||||||
|
*/
|
||||||
|
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
|
||||||
|
const defaultResult: ParsedClipboardContent = {
|
||||||
|
message: clipboardText,
|
||||||
|
textAttachments: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!clipboardText.startsWith('"')) {
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let stringEndIndex = -1;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let i = 1; i < clipboardText.length; i++) {
|
||||||
|
const char = clipboardText[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
stringEndIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringEndIndex === -1) {
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
|
||||||
|
const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
|
||||||
|
|
||||||
|
const message = JSON.parse(jsonStringPart) as string;
|
||||||
|
|
||||||
|
if (!remainingPart || !remainingPart.startsWith('[')) {
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
textAttachments: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = JSON.parse(remainingPart) as unknown[];
|
||||||
|
|
||||||
|
const validAttachments: ClipboardTextAttachment[] = [];
|
||||||
|
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (isValidTextAttachment(att)) {
|
||||||
|
validAttachments.push({
|
||||||
|
type: AttachmentType.TEXT,
|
||||||
|
name: att.name,
|
||||||
|
content: att.content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
textAttachments: validAttachments
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to validate a text attachment object
|
||||||
|
* @param obj The object to validate
|
||||||
|
* @returns true if the object is a valid text attachment
|
||||||
|
*/
|
||||||
|
function isValidTextAttachment(
|
||||||
|
obj: unknown
|
||||||
|
): obj is { type: string; name: string; content: string } {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = obj as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
|
||||||
|
typeof record.name === 'string' &&
|
||||||
|
typeof record.content === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if clipboard content contains our special format with attachments
|
||||||
|
* @param clipboardText - Raw text from clipboard
|
||||||
|
* @returns true if the clipboard content contains our special format with attachments
|
||||||
|
*/
|
||||||
|
export function hasClipboardAttachments(clipboardText: string): boolean {
|
||||||
|
if (!clipboardText.startsWith('"')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseClipboardContent(clipboardText);
|
||||||
|
return parsed.textAttachments.length > 0;
|
||||||
|
}
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy text to clipboard with toast notification
|
|
||||||
* Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
|
|
||||||
* @param text - Text to copy to clipboard
|
|
||||||
* @param successMessage - Custom success message (optional)
|
|
||||||
* @param errorMessage - Custom error message (optional)
|
|
||||||
* @returns Promise<boolean> - True if successful, false otherwise
|
|
||||||
*/
|
|
||||||
export async function copyToClipboard(
|
|
||||||
text: string,
|
|
||||||
successMessage = 'Copied to clipboard',
|
|
||||||
errorMessage = 'Failed to copy to clipboard'
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Try modern clipboard API first (secure contexts only)
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
toast.success(successMessage);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for non-secure contexts
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = text;
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.left = '-999999px';
|
|
||||||
textArea.style.top = '-999999px';
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
|
|
||||||
if (successful) {
|
|
||||||
toast.success(successMessage);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
throw new Error('execCommand failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy to clipboard:', error);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy code with HTML entity decoding and toast notification
|
|
||||||
* @param rawCode - Raw code string that may contain HTML entities
|
|
||||||
* @param successMessage - Custom success message (optional)
|
|
||||||
* @param errorMessage - Custom error message (optional)
|
|
||||||
* @returns Promise<boolean> - True if successful, false otherwise
|
|
||||||
*/
|
|
||||||
export async function copyCodeToClipboard(
|
|
||||||
rawCode: string,
|
|
||||||
successMessage = 'Code copied to clipboard',
|
|
||||||
errorMessage = 'Failed to copy code'
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Decode HTML entities
|
|
||||||
const decodedCode = rawCode
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
return copyToClipboard(decodedCode, successMessage, errorMessage);
|
|
||||||
}
|
|
||||||
|
|
@ -40,7 +40,15 @@ export { setConfigValue, getConfigValue, configToParameterRecord } from './confi
|
||||||
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
||||||
|
|
||||||
// Clipboard utilities
|
// Clipboard utilities
|
||||||
export { copyToClipboard, copyCodeToClipboard } from './copy';
|
export {
|
||||||
|
copyToClipboard,
|
||||||
|
copyCodeToClipboard,
|
||||||
|
formatMessageForClipboard,
|
||||||
|
parseClipboardContent,
|
||||||
|
hasClipboardAttachments,
|
||||||
|
type ClipboardTextAttachment,
|
||||||
|
type ParsedClipboardContent
|
||||||
|
} from './clipboard';
|
||||||
|
|
||||||
// File preview utilities
|
// File preview utilities
|
||||||
export { getFileTypeLabel } from './file-preview';
|
export { getFileTypeLabel } from './file-preview';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AttachmentType } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
formatMessageForClipboard,
|
||||||
|
parseClipboardContent,
|
||||||
|
hasClipboardAttachments
|
||||||
|
} from '$lib/utils/clipboard';
|
||||||
|
|
||||||
|
describe('formatMessageForClipboard', () => {
|
||||||
|
it('returns plain content when no extras', () => {
|
||||||
|
const result = formatMessageForClipboard('Hello world', undefined);
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain content when extras is empty array', () => {
|
||||||
|
const result = formatMessageForClipboard('Hello world', []);
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string content', () => {
|
||||||
|
const result = formatMessageForClipboard('', undefined);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain content when extras has only non-text attachments', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.IMAGE as const,
|
||||||
|
name: 'image.png',
|
||||||
|
base64Url: 'data:image/png;base64,...'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello world', extras);
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters non-text attachments and keeps only text ones', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.IMAGE as const,
|
||||||
|
name: 'image.png',
|
||||||
|
base64Url: 'data:image/png;base64,...'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file.txt',
|
||||||
|
content: 'Text content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AttachmentType.PDF as const,
|
||||||
|
name: 'doc.pdf',
|
||||||
|
base64Data: 'data:application/pdf;base64,...',
|
||||||
|
content: 'PDF content',
|
||||||
|
processedAsImages: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello', extras);
|
||||||
|
|
||||||
|
expect(result).toContain('"file.txt"');
|
||||||
|
expect(result).not.toContain('image.png');
|
||||||
|
expect(result).not.toContain('doc.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats message with text attachments', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file1.txt',
|
||||||
|
content: 'File 1 content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file2.txt',
|
||||||
|
content: 'File 2 content'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello world', extras);
|
||||||
|
|
||||||
|
expect(result).toContain('"Hello world"');
|
||||||
|
expect(result).toContain('"type": "TEXT"');
|
||||||
|
expect(result).toContain('"name": "file1.txt"');
|
||||||
|
expect(result).toContain('"content": "File 1 content"');
|
||||||
|
expect(result).toContain('"name": "file2.txt"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles content with quotes and special characters', () => {
|
||||||
|
const content = 'Hello "world" with\nnewline';
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'test.txt',
|
||||||
|
content: 'Test content'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard(content, extras);
|
||||||
|
|
||||||
|
// Should be valid JSON
|
||||||
|
expect(result.startsWith('"')).toBe(true);
|
||||||
|
// The content should be properly escaped
|
||||||
|
const parsed = JSON.parse(result.split('\n')[0]);
|
||||||
|
expect(parsed).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts legacy context type to TEXT type', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.LEGACY_CONTEXT as const,
|
||||||
|
name: 'legacy.txt',
|
||||||
|
content: 'Legacy content'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello', extras);
|
||||||
|
|
||||||
|
expect(result).toContain('"type": "TEXT"');
|
||||||
|
expect(result).not.toContain('"context"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles attachment content with special characters', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'code.js',
|
||||||
|
content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard('Check this code', extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.textAttachments[0].content).toBe(
|
||||||
|
'const x = "hello\\nworld";\nconst y = `template ${var}`;'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode characters in content and attachments', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'unicode.txt',
|
||||||
|
content: '日本語テスト 🎉 émojis'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard('Привет мир 👋', extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe('Привет мир 👋');
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats as plain text when asPlainText is true', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file1.txt',
|
||||||
|
content: 'File 1 content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file2.txt',
|
||||||
|
content: 'File 2 content'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello world', extras, true);
|
||||||
|
|
||||||
|
expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain content when asPlainText is true but no attachments', () => {
|
||||||
|
const result = formatMessageForClipboard('Hello world', [], true);
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain text mode does not use JSON format', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'test.txt',
|
||||||
|
content: 'Test content'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const result = formatMessageForClipboard('Hello', extras, true);
|
||||||
|
|
||||||
|
expect(result).not.toContain('"type"');
|
||||||
|
expect(result).not.toContain('[');
|
||||||
|
expect(result).toBe('Hello\n\nTest content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseClipboardContent', () => {
|
||||||
|
it('returns plain text as message when not in special format', () => {
|
||||||
|
const result = parseClipboardContent('Hello world');
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello world');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string input', () => {
|
||||||
|
const result = parseClipboardContent('');
|
||||||
|
|
||||||
|
expect(result.message).toBe('');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles whitespace-only input', () => {
|
||||||
|
const result = parseClipboardContent(' \n\t ');
|
||||||
|
|
||||||
|
expect(result.message).toBe(' \n\t ');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain text as message when starts with quote but invalid format', () => {
|
||||||
|
const result = parseClipboardContent('"Unclosed quote');
|
||||||
|
|
||||||
|
expect(result.message).toBe('"Unclosed quote');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original text when JSON array is malformed', () => {
|
||||||
|
const input = '"Hello"\n[invalid json';
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('"Hello"\n[invalid json');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses message with text attachments', () => {
|
||||||
|
const input = `"Hello world"
|
||||||
|
[
|
||||||
|
{"type":"TEXT","name":"file1.txt","content":"File 1 content"},
|
||||||
|
{"type":"TEXT","name":"file2.txt","content":"File 2 content"}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello world');
|
||||||
|
expect(result.textAttachments).toHaveLength(2);
|
||||||
|
expect(result.textAttachments[0].name).toBe('file1.txt');
|
||||||
|
expect(result.textAttachments[0].content).toBe('File 1 content');
|
||||||
|
expect(result.textAttachments[1].name).toBe('file2.txt');
|
||||||
|
expect(result.textAttachments[1].content).toBe('File 2 content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped quotes in message', () => {
|
||||||
|
const input = `"Hello \\"world\\" with quotes"
|
||||||
|
[
|
||||||
|
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello "world" with quotes');
|
||||||
|
expect(result.textAttachments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles newlines in message', () => {
|
||||||
|
const input = `"Hello\\nworld"
|
||||||
|
[
|
||||||
|
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello\nworld');
|
||||||
|
expect(result.textAttachments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns message only when no array follows', () => {
|
||||||
|
const input = '"Just a quoted string"';
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Just a quoted string');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out invalid attachment objects', () => {
|
||||||
|
const input = `"Hello"
|
||||||
|
[
|
||||||
|
{"type":"TEXT","name":"valid.txt","content":"valid"},
|
||||||
|
{"type":"INVALID","name":"invalid.txt","content":"invalid"},
|
||||||
|
{"name":"missing-type.txt","content":"missing"},
|
||||||
|
{"type":"TEXT","content":"missing name"}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello');
|
||||||
|
expect(result.textAttachments).toHaveLength(1);
|
||||||
|
expect(result.textAttachments[0].name).toBe('valid.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty attachments array', () => {
|
||||||
|
const input = '"Hello"\n[]';
|
||||||
|
|
||||||
|
const result = parseClipboardContent(input);
|
||||||
|
|
||||||
|
expect(result.message).toBe('Hello');
|
||||||
|
expect(result.textAttachments).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips correctly with formatMessageForClipboard', () => {
|
||||||
|
const originalContent = 'Hello "world" with\nspecial characters';
|
||||||
|
const originalExtras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file1.txt',
|
||||||
|
content: 'Content with\nnewlines and "quotes"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file2.txt',
|
||||||
|
content: 'Another file'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatted = formatMessageForClipboard(originalContent, originalExtras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe(originalContent);
|
||||||
|
expect(parsed.textAttachments).toHaveLength(2);
|
||||||
|
expect(parsed.textAttachments[0].name).toBe('file1.txt');
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"');
|
||||||
|
expect(parsed.textAttachments[1].name).toBe('file2.txt');
|
||||||
|
expect(parsed.textAttachments[1].content).toBe('Another file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasClipboardAttachments', () => {
|
||||||
|
it('returns false for plain text', () => {
|
||||||
|
expect(hasClipboardAttachments('Hello world')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(hasClipboardAttachments('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for quoted string without attachments', () => {
|
||||||
|
expect(hasClipboardAttachments('"Hello world"')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for valid format with attachments', () => {
|
||||||
|
const input = `"Hello"
|
||||||
|
[{"type":"TEXT","name":"file.txt","content":"test"}]`;
|
||||||
|
|
||||||
|
expect(hasClipboardAttachments(input)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for format with empty attachments array', () => {
|
||||||
|
const input = '"Hello"\n[]';
|
||||||
|
|
||||||
|
expect(hasClipboardAttachments(input)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for malformed JSON', () => {
|
||||||
|
expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roundtrip edge cases', () => {
|
||||||
|
it('preserves empty message with attachments', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'file.txt',
|
||||||
|
content: 'Content only'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard('', extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe('');
|
||||||
|
expect(parsed.textAttachments).toHaveLength(1);
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('Content only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves attachment with empty content', () => {
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'empty.txt',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard('Message', extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe('Message');
|
||||||
|
expect(parsed.textAttachments).toHaveLength(1);
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves multiple backslashes', () => {
|
||||||
|
const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'path.txt',
|
||||||
|
content: 'D:\\\\Data\\\\file'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard(content, extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe(content);
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves tabs and various whitespace', () => {
|
||||||
|
const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF';
|
||||||
|
const extras = [
|
||||||
|
{
|
||||||
|
type: AttachmentType.TEXT as const,
|
||||||
|
name: 'whitespace.txt',
|
||||||
|
content: '\t\t\n\n '
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const formatted = formatMessageForClipboard(content, extras);
|
||||||
|
const parsed = parseClipboardContent(formatted);
|
||||||
|
|
||||||
|
expect(parsed.message).toBe(content);
|
||||||
|
expect(parsed.textAttachments[0].content).toBe('\t\t\n\n ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable no-irregular-whitespace */
|
/* eslint-disable no-irregular-whitespace */
|
||||||
import { describe, it, expect, test } from 'vitest';
|
import { describe, it, expect, test } from 'vitest';
|
||||||
import { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
|
import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||||
|
|
||||||
describe('maskInlineLaTeX', () => {
|
describe('maskInlineLaTeX', () => {
|
||||||
it('should protect LaTeX $x + y$ but not money $3.99', () => {
|
it('should protect LaTeX $x + y$ but not money $3.99', () => {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isValidModelName, normalizeModelName } from './model-names';
|
import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
|
||||||
|
|
||||||
describe('normalizeModelName', () => {
|
describe('normalizeModelName', () => {
|
||||||
it('preserves Hugging Face org/model format (single slash)', () => {
|
it('preserves Hugging Face org/model format (single slash)', () => {
|
||||||
|
|
@ -125,9 +125,9 @@ export default defineConfig({
|
||||||
{
|
{
|
||||||
extends: './vite.config.ts',
|
extends: './vite.config.ts',
|
||||||
test: {
|
test: {
|
||||||
name: 'server',
|
name: 'unit',
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/server/**/*.{test,spec}.{js,ts}']
|
include: ['tests/unit/**/*.{test,spec}.{js,ts}']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue