瀏覽代碼

feat/enh: insert prompt as rich text

Timothy Jaeryang Baek 3 月之前
父節點
當前提交
5722da8e3b

+ 1 - 0
src/lib/components/chat/MessageInput.svelte

@@ -1182,6 +1182,7 @@
 												}}
 												json={true}
 												messageInput={true}
+												insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
 												shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
 													(!$mobile ||
 														!(

+ 39 - 6
src/lib/components/chat/Settings/Interface.svelte

@@ -38,6 +38,7 @@
 	let detectArtifacts = true;
 
 	let richTextInput = true;
+	let insertPromptAsRichText = false;
 	let promptAutocomplete = false;
 
 	let largeTextAsFile = false;
@@ -218,6 +219,11 @@
 		saveSettings({ richTextInput });
 	};
 
+	const toggleInsertPromptAsRichText = async () => {
+		insertPromptAsRichText = !insertPromptAsRichText;
+		saveSettings({ insertPromptAsRichText });
+	};
+
 	const toggleLargeTextAsFile = async () => {
 		largeTextAsFile = !largeTextAsFile;
 		saveSettings({ largeTextAsFile });
@@ -308,7 +314,9 @@
 		voiceInterruption = $settings?.voiceInterruption ?? false;
 
 		richTextInput = $settings?.richTextInput ?? true;
+		insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false;
 		promptAutocomplete = $settings?.promptAutocomplete ?? false;
+
 		largeTextAsFile = $settings?.largeTextAsFile ?? false;
 		copyFormatted = $settings?.copyFormatted ?? false;
 
@@ -761,22 +769,22 @@
 				</div>
 			</div>
 
-			{#if $config?.features?.enable_autocomplete_generation && richTextInput}
+			{#if richTextInput}
 				<div>
 					<div class=" py-0.5 flex w-full justify-between">
-						<div id="prompt-autocompletion-label" class=" self-center text-xs">
-							{$i18n.t('Prompt Autocompletion')}
+						<div id="rich-input-label" class=" self-center text-xs">
+							{$i18n.t('Insert Prompt as Rich Text')}
 						</div>
 
 						<button
-							aria-labelledby="prompt-autocompletion-label"
+							aria-labelledby="rich-input-label"
 							class="p-1 px-3 text-xs flex rounded-sm transition"
 							on:click={() => {
-								togglePromptAutocomplete();
+								toggleInsertPromptAsRichText();
 							}}
 							type="button"
 						>
-							{#if promptAutocomplete === true}
+							{#if insertPromptAsRichText === true}
 								<span class="ml-2 self-center">{$i18n.t('On')}</span>
 							{:else}
 								<span class="ml-2 self-center">{$i18n.t('Off')}</span>
@@ -784,6 +792,31 @@
 						</button>
 					</div>
 				</div>
+
+				{#if $config?.features?.enable_autocomplete_generation}
+					<div>
+						<div class=" py-0.5 flex w-full justify-between">
+							<div id="prompt-autocompletion-label" class=" self-center text-xs">
+								{$i18n.t('Prompt Autocompletion')}
+							</div>
+
+							<button
+								aria-labelledby="prompt-autocompletion-label"
+								class="p-1 px-3 text-xs flex rounded-sm transition"
+								on:click={() => {
+									togglePromptAutocomplete();
+								}}
+								type="button"
+							>
+								{#if promptAutocomplete === 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>
+				{/if}
 			{/if}
 
 			<div>

+ 68 - 33
src/lib/components/common/RichTextInput.svelte

@@ -1,5 +1,6 @@
 <script lang="ts">
 	import { marked } from 'marked';
+
 	import TurndownService from 'turndown';
 	import { gfm } from 'turndown-plugin-gfm';
 	const turndownService = new TurndownService({
@@ -7,7 +8,6 @@
 		headingStyle: 'atx'
 	});
 	turndownService.escape = (string) => string;
-
 	// Use turndown-plugin-gfm for proper GFM table support
 	turndownService.use(gfm);
 
@@ -16,8 +16,8 @@
 
 	const eventDispatch = createEventDispatcher();
 
-	import { Fragment } from 'prosemirror-model';
-	import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
+	import { Fragment, DOMParser } from 'prosemirror-model';
+	import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
 	import { Decoration, DecorationSet } from 'prosemirror-view';
 	import { Editor } from '@tiptap/core';
 
@@ -29,10 +29,10 @@
 
 	import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
 	import Placeholder from '@tiptap/extension-placeholder';
-	import { all, createLowlight } from 'lowlight';
 	import StarterKit from '@tiptap/starter-kit';
 	import Highlight from '@tiptap/extension-highlight';
 	import Typography from '@tiptap/extension-typography';
+	import { all, createLowlight } from 'lowlight';
 
 	import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
 
@@ -60,6 +60,7 @@
 	export let messageInput = false;
 	export let shiftEnter = false;
 	export let largeTextAsFile = false;
+	export let insertPromptAsRichText = false;
 
 	let element;
 	let editor;
@@ -130,40 +131,74 @@
 
 		let tr = state.tr;
 
-		if (text.includes('\n')) {
-			// Split the text into lines and create a <p> node for each line
-			const lines = text.split('\n');
-			const nodes = lines.map(
-				(line, index) =>
-					index === 0
-						? state.schema.text(line ? line : []) // First line is plain text
-						: state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
-			);
+		if (insertPromptAsRichText) {
+			const htmlContent = marked
+				.parse(text, {
+					breaks: true,
+					gfm: true
+				})
+				.trim();
+
+			// Create a temporary div to parse HTML
+			const tempDiv = document.createElement('div');
+			tempDiv.innerHTML = htmlContent;
+
+			// Convert HTML to ProseMirror nodes
+			const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv);
+
+			// Extract just the content, not the wrapper paragraphs
+			const content = fragment.content;
+			let nodesToInsert = [];
+
+			content.forEach((node) => {
+				if (node.type.name === 'paragraph') {
+					// If it's a paragraph, extract its content
+					nodesToInsert.push(...node.content.content);
+				} else {
+					nodesToInsert.push(node);
+				}
+			});
 
-			// Build and dispatch the transaction to replace the word at cursor
-			tr = tr.replaceWith(start, end, nodes);
+			tr = tr.replaceWith(start, end, nodesToInsert);
+			// Calculate new position
+			const newPos = start + nodesToInsert.reduce((sum, node) => sum + node.nodeSize, 0);
+			tr = tr.setSelection(Selection.near(tr.doc.resolve(newPos)));
+		} else {
+			if (text.includes('\n')) {
+				// Split the text into lines and create a <p> node for each line
+				const lines = text.split('\n');
+				const nodes = lines.map(
+					(line, index) =>
+						index === 0
+							? state.schema.text(line ? line : []) // First line is plain text
+							: state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
+				);
 
-			let newSelectionPos;
+				// Build and dispatch the transaction to replace the word at cursor
+				tr = tr.replaceWith(start, end, nodes);
 
-			// +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
-			let lastPos = start;
-			for (let i = 0; i < nodes.length; i++) {
-				lastPos += nodes[i].nodeSize;
-			}
-			// Place cursor inside the last paragraph at its end
-			newSelectionPos = lastPos;
+				let newSelectionPos;
 
-			tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
-		} else {
-			tr = tr.replaceWith(
-				start,
-				end, // replace this range
-				text !== '' ? state.schema.text(text) : []
-			);
+				// +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
+				let lastPos = start;
+				for (let i = 0; i < nodes.length; i++) {
+					lastPos += nodes[i].nodeSize;
+				}
+				// Place cursor inside the last paragraph at its end
+				newSelectionPos = lastPos;
 
-			tr = tr.setSelection(
-				state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
-			);
+				tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
+			} else {
+				tr = tr.replaceWith(
+					start,
+					end, // replace this range
+					text !== '' ? state.schema.text(text) : []
+				);
+
+				tr = tr.setSelection(
+					state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
+				);
+			}
 		}
 
 		dispatch(tr);