feat/enh: insert prompt as rich text

This commit is contained in:
Timothy Jaeryang Baek 2025-07-07 21:43:28 +04:00
parent ade4b0d691
commit 5722da8e3b
3 changed files with 107 additions and 38 deletions

View file

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

View file

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

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { marked } from 'marked'; import { marked } from 'marked';
import TurndownService from 'turndown'; import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm'; import { gfm } from 'turndown-plugin-gfm';
const turndownService = new TurndownService({ const turndownService = new TurndownService({
@ -7,7 +8,6 @@
headingStyle: 'atx' headingStyle: 'atx'
}); });
turndownService.escape = (string) => string; turndownService.escape = (string) => string;
// Use turndown-plugin-gfm for proper GFM table support // Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm); turndownService.use(gfm);
@ -16,8 +16,8 @@
const eventDispatch = createEventDispatcher(); const eventDispatch = createEventDispatcher();
import { Fragment } from 'prosemirror-model'; import { Fragment, DOMParser } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
@ -29,10 +29,10 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { all, createLowlight } from 'lowlight';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight'; import Highlight from '@tiptap/extension-highlight';
import Typography from '@tiptap/extension-typography'; import Typography from '@tiptap/extension-typography';
import { all, createLowlight } from 'lowlight';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
@ -60,6 +60,7 @@
export let messageInput = false; export let messageInput = false;
export let shiftEnter = false; export let shiftEnter = false;
export let largeTextAsFile = false; export let largeTextAsFile = false;
export let insertPromptAsRichText = false;
let element; let element;
let editor; let editor;
@ -130,6 +131,39 @@
let tr = state.tr; let tr = state.tr;
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);
}
});
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')) { if (text.includes('\n')) {
// Split the text into lines and create a <p> node for each line // Split the text into lines and create a <p> node for each line
const lines = text.split('\n'); const lines = text.split('\n');
@ -165,6 +199,7 @@
state.selection.constructor.near(tr.doc.resolve(start + text.length + 1)) state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
); );
} }
}
dispatch(tr); dispatch(tr);