Explorar el Código

enh: copy formatted option

Co-Authored-By: Sebastian Whincop <123417897+macjedi42@users.noreply.github.com>
Timothy Jaeryang Baek hace 3 meses
padre
commit
aa8db40376

+ 2 - 1
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -156,7 +156,8 @@
 
 	const copyToClipboard = async (text) => {
 		text = removeAllDetails(text);
-		const res = await _copyToClipboard(text);
+
+		const res = await _copyToClipboard(text, $settings?.copyFormatted ?? false);
 		if (res) {
 			toast.success($i18n.t('Copying to clipboard was successful!'));
 		}

+ 29 - 0
src/lib/components/chat/Settings/Interface.svelte

@@ -43,6 +43,7 @@
 	let chatBubble = true;
 	let chatDirection: 'LTR' | 'RTL' | 'auto' = 'auto';
 	let ctrlEnterToSend = false;
+	let copyFormatted = false;
 
 	let collapseCodeBlocks = false;
 	let expandDetails = false;
@@ -220,6 +221,11 @@
 		}
 	};
 
+	const toggleCopyFormatted = async () => {
+		copyFormatted = !copyFormatted;
+		saveSettings({ copyFormatted });
+	};
+
 	const toggleChangeChatDirection = async () => {
 		if (chatDirection === 'auto') {
 			chatDirection = 'LTR';
@@ -275,6 +281,7 @@
 		richTextInput = $settings.richTextInput ?? true;
 		promptAutocomplete = $settings.promptAutocomplete ?? false;
 		largeTextAsFile = $settings.largeTextAsFile ?? false;
+		copyFormatted = $settings.copyFormatted ?? false;
 
 		collapseCodeBlocks = $settings.collapseCodeBlocks ?? false;
 		expandDetails = $settings.expandDetails ?? false;
@@ -670,6 +677,28 @@
 				</div>
 			</div>
 
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div class=" self-center text-xs">
+						{$i18n.t('Copy Formatted Text')}
+					</div>
+
+					<button
+						class="p-1 px-3 text-xs flex rounded-sm transition"
+						on:click={() => {
+							toggleCopyFormatted();
+						}}
+						type="button"
+					>
+						{#if copyFormatted === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
 					<div class=" self-center text-xs">{$i18n.t('Always Collapse Code Blocks')}</div>

+ 120 - 32
src/lib/utils/index.ts

@@ -15,6 +15,11 @@ dayjs.extend(localizedFormat);
 import { WEBUI_BASE_URL } from '$lib/constants';
 import { TTS_RESPONSE_SPLIT } from '$lib/types';
 
+import { marked } from 'marked';
+import markedExtension from '$lib/utils/marked/extension';
+import markedKatexExtension from '$lib/utils/marked/katex-extension';
+import hljs from 'highlight.js';
+
 //////////////////////////
 // Helper functions
 //////////////////////////
@@ -309,46 +314,129 @@ export const formatDate = (inputDate) => {
 	}
 };
 
-export const copyToClipboard = async (text) => {
-	let result = false;
-	if (!navigator.clipboard) {
-		const textArea = document.createElement('textarea');
-		textArea.value = text;
-
-		// Avoid scrolling to bottom
-		textArea.style.top = '0';
-		textArea.style.left = '0';
-		textArea.style.position = 'fixed';
+export const copyToClipboard = async (text, formatted = false) => {
+	if (formatted) {
+		const options = {
+			throwOnError: false,
+			highlight: function (code, lang) {
+				const language = hljs.getLanguage(lang) ? lang : 'plaintext';
+				return hljs.highlight(code, { language }).value;
+			}
+		};
+		marked.use(markedKatexExtension(options));
+		marked.use(markedExtension(options));
+
+		const htmlContent = marked.parse(text);
+
+		// Add basic styling to make the content look better when pasted
+		const styledHtml = `
+			<div>
+				<style>
+					pre {
+						background-color: #f6f8fa;
+						border-radius: 6px;
+						padding: 16px;
+						overflow: auto;
+					}
+					code {
+						font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+						font-size: 14px;
+					}
+					.hljs-keyword { color: #d73a49; }
+					.hljs-string { color: #032f62; }
+					.hljs-comment { color: #6a737d; }
+					.hljs-function { color: #6f42c1; }
+					.hljs-number { color: #005cc5; }
+					.hljs-operator { color: #d73a49; }
+					.hljs-class { color: #6f42c1; }
+					.hljs-title { color: #6f42c1; }
+					.hljs-params { color: #24292e; }
+					.hljs-built_in { color: #005cc5; }
+					blockquote {
+						border-left: 4px solid #dfe2e5;
+						padding-left: 16px;
+						color: #6a737d;
+						margin-left: 0;
+						margin-right: 0;
+					}
+					table {
+						border-collapse: collapse;
+						width: 100%;
+						margin-bottom: 16px;
+					}
+					table, th, td {
+						border: 1px solid #dfe2e5;
+					}
+					th, td {
+						padding: 8px 12px;
+					}
+					th {
+						background-color: #f6f8fa;
+					}
+				</style>
+				${htmlContent}
+			</div>
+		`;
 
-		document.body.appendChild(textArea);
-		textArea.focus();
-		textArea.select();
+		// Create a blob with HTML content
+		const blob = new Blob([styledHtml], { type: 'text/html' });
 
 		try {
-			const successful = document.execCommand('copy');
-			const msg = successful ? 'successful' : 'unsuccessful';
-			console.log('Fallback: Copying text command was ' + msg);
-			result = true;
+			// Create a ClipboardItem with HTML content
+			const data = new ClipboardItem({
+				'text/html': blob,
+				'text/plain': new Blob([text], { type: 'text/plain' })
+			});
+
+			// Write to clipboard
+			await navigator.clipboard.write([data]);
+			return true;
 		} catch (err) {
-			console.error('Fallback: Oops, unable to copy', err);
+			console.error('Error copying formatted content:', err);
+			// Fallback to plain text
+			return await copyToClipboard(text);
 		}
+	} else {
+		let result = false;
+		if (!navigator.clipboard) {
+			const textArea = document.createElement('textarea');
+			textArea.value = text;
+
+			// Avoid scrolling to bottom
+			textArea.style.top = '0';
+			textArea.style.left = '0';
+			textArea.style.position = 'fixed';
+
+			document.body.appendChild(textArea);
+			textArea.focus();
+			textArea.select();
+
+			try {
+				const successful = document.execCommand('copy');
+				const msg = successful ? 'successful' : 'unsuccessful';
+				console.log('Fallback: Copying text command was ' + msg);
+				result = true;
+			} catch (err) {
+				console.error('Fallback: Oops, unable to copy', err);
+			}
 
-		document.body.removeChild(textArea);
-		return result;
-	}
+			document.body.removeChild(textArea);
+			return result;
+		}
 
-	result = await navigator.clipboard
-		.writeText(text)
-		.then(() => {
-			console.log('Async: Copying to clipboard was successful!');
-			return true;
-		})
-		.catch((error) => {
-			console.error('Async: Could not copy text: ', error);
-			return false;
-		});
+		result = await navigator.clipboard
+			.writeText(text)
+			.then(() => {
+				console.log('Async: Copying to clipboard was successful!');
+				return true;
+			})
+			.catch((error) => {
+				console.error('Async: Could not copy text: ', error);
+				return false;
+			});
 
-	return result;
+		return result;
+	}
 };
 
 export const compareVersion = (latest, current) => {