Selaa lähdekoodia

Merge pull request #349 from ollama-webui/prompts

feat: prompt preset support
Timothy Jaeryang Baek 1 vuosi sitten
vanhempi
commit
14efed3651

+ 0 - 1
.gitignore

@@ -24,7 +24,6 @@ dist/
 downloads/
 eggs/
 .eggs/
-lib/
 lib64/
 parts/
 sdist/

+ 97 - 31
src/lib/components/chat/MessageInput.svelte

@@ -1,8 +1,11 @@
 <script lang="ts">
-	import { settings } from '$lib/stores';
 	import toast from 'svelte-french-toast';
+	import { onMount, tick } from 'svelte';
+	import { settings } from '$lib/stores';
+	import { findWordIndices } from '$lib/utils';
+
+	import Prompts from './MessageInput/PromptCommands.svelte';
 	import Suggestions from './MessageInput/Suggestions.svelte';
-	import { onMount } from 'svelte';
 
 	export let submitPrompt: Function;
 	export let stopResponse: Function;
@@ -11,6 +14,8 @@
 	export let autoScroll = true;
 
 	let filesInputElement;
+	let promptsElement;
+
 	let inputFiles;
 	let dragged = false;
 
@@ -154,36 +159,42 @@
 
 <div class="fixed bottom-0 w-full">
 	<div class="px-2.5 pt-2.5 -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
-		{#if messages.length == 0 && suggestionPrompts.length !== 0}
-			<div class="max-w-3xl w-full">
-				<Suggestions {suggestionPrompts} {submitPrompt} />
+		<div class="flex flex-col max-w-3xl w-full">
+			<div>
+				{#if autoScroll === false && messages.length > 0}
+					<div class=" flex justify-center mb-4">
+						<button
+							class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
+							on:click={() => {
+								autoScroll = true;
+								window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
+							}}
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-5 h-5"
+							>
+								<path
+									fill-rule="evenodd"
+									d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+						</button>
+					</div>
+				{/if}
 			</div>
-		{/if}
-
-		{#if autoScroll === false && messages.length > 0}
-			<div class=" flex justify-center mb-4">
-				<button
-					class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
-					on:click={() => {
-						autoScroll = true;
-						window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
-					}}
-				>
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 20 20"
-						fill="currentColor"
-						class="w-5 h-5"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
-							clip-rule="evenodd"
-						/>
-					</svg>
-				</button>
+
+			<div class="w-full">
+				{#if prompt.charAt(0) === '/'}
+					<Prompts bind:this={promptsElement} bind:prompt />
+				{:else if messages.length == 0 && suggestionPrompts.length !== 0}
+					<Suggestions {suggestionPrompts} {submitPrompt} />
+				{/if}
 			</div>
-		{/if}
+		</div>
 	</div>
 	<div class="bg-white dark:bg-gray-800">
 		<div class="max-w-3xl px-2.5 -mb-0.5 mx-auto inset-x-0">
@@ -298,7 +309,7 @@
 									submitPrompt(prompt);
 								}
 							}}
-							on:keydown={(e) => {
+							on:keydown={async (e) => {
 								if (prompt === '' && e.key == 'ArrowUp') {
 									e.preventDefault();
 
@@ -315,6 +326,61 @@
 									userMessageElement.scrollIntoView({ block: 'center' });
 									editButton?.click();
 								}
+
+								if (prompt.charAt(0) === '/' && e.key === 'ArrowUp') {
+									promptsElement.selectUp();
+
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+									commandOptionButton.scrollIntoView({ block: 'center' });
+								}
+
+								if (prompt.charAt(0) === '/' && e.key === 'ArrowDown') {
+									promptsElement.selectDown();
+
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+									commandOptionButton.scrollIntoView({ block: 'center' });
+								}
+
+								if (prompt.charAt(0) === '/' && e.key === 'Enter') {
+									e.preventDefault();
+
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+
+									commandOptionButton?.click();
+								}
+
+								if (prompt.charAt(0) === '/' && e.key === 'Tab') {
+									e.preventDefault();
+
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+
+									commandOptionButton?.click();
+								} else if (e.key === 'Tab') {
+									const words = findWordIndices(prompt);
+
+									if (words.length > 0) {
+										const word = words.at(0);
+										const fullPrompt = prompt;
+
+										prompt = prompt.substring(0, word?.endIndex + 1);
+										await tick();
+
+										e.target.scrollTop = e.target.scrollHeight;
+										prompt = fullPrompt;
+										await tick();
+
+										e.preventDefault();
+										e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
+									}
+								}
 							}}
 							rows="1"
 							on:input={(e) => {

+ 227 - 0
src/lib/components/chat/MessageInput/PromptCommands.svelte

@@ -0,0 +1,227 @@
+<script lang="ts">
+	import { findWordIndices } from '$lib/utils';
+	import { tick } from 'svelte';
+
+	export let prompt = '';
+
+	let selectedCommandIdx = 0;
+
+	let promptCommands = [
+		{
+			command: '/article',
+			title: 'Article Generator',
+			content: `Write an article about [topic]
+
+include relevant statistics (add the links of the sources you use) and consider diverse perspectives. Write it in a [X_tone] and mention the source links in the end.`
+		},
+		{
+			command: '/backlink',
+
+			title: 'Backlink Outreach Email',
+			content: `Write a link-exchange outreach email on behalf of [your name] from [your_company] to ask for a backlink from their [website_url] to [your website url].`
+		},
+		{
+			command: '/faq',
+
+			title: 'FAQ Generator',
+			content: `Create a list of [10] frequently asked questions about [keyword] and provide answers for each one of them considering the SERP and rich result guidelines.`
+		},
+		{
+			command: '/headline',
+
+			title: 'Headline Generator',
+			content: `Generate 10 attention-grabbing headlines for an article about [your topic]`
+		},
+		{
+			command: '/product',
+
+			title: 'Product Description',
+			content: `Craft an irresistible product description that highlights the benefits of [your product]`
+		},
+		{
+			command: '/seo',
+
+			title: 'SEO Content Brief',
+			content: `Create a SEO content brief for [keyword].`
+		},
+		{
+			command: '/seo-ideas',
+
+			title: 'SEO Keyword Ideas',
+			content: `Generate a list of 20 keyword ideas on [topic].
+
+Cluster this list of keywords according to funnel stages whether they are top of the funnel, middle of the funnel or bottom of the funnel keywords.`
+		},
+		{
+			command: '/summary',
+
+			title: 'Short Summary',
+			content: `Write a summary in 50 words that summarizes [topic or keyword].`
+		},
+		{
+			command: '/email-subject',
+
+			title: 'Email Subject Line',
+			content: `Develop [5] subject lines for a cold email offering your [product or service] to a potential client.`
+		},
+		{
+			command: '/facebook-ads',
+
+			title: 'Facebook Ads',
+			content: `Create 3 variations of effective ad copy to promote [product] for [audience].
+
+Make sure they are [persuasive/playful/emotional] and mention these benefits:
+
+[Benefit 1]
+
+[Benefit 2]
+
+[Benefit 3]
+
+Finish with a call to action saying [CTA].
+
+Add 3 emojis to it.`
+		},
+		{
+			command: '/google-ads',
+
+			title: 'Google Ads',
+			content: `Create 10 google ads (a headline and a description) for [product description] targeting the keyword [keyword].
+
+The headline of the ad needs to be under 30 characters. The description needs to be under 90 characters. Format the output as a table.`
+		},
+		{
+			command: '/insta-caption',
+
+			title: 'Instagram Caption',
+			content: `Write 5 variations of Instagram captions for [product].
+
+Use friendly, human-like language that appeals to [target audience].
+
+Emphasize the unique qualities of [product],
+
+use ample emojis, and don't sound too promotional.`
+		},
+		{
+			command: '/linkedin-post',
+
+			title: 'LinkedIn Post',
+			content: `Create a narrative Linkedin post using immersive writing about [topic].
+
+Details:
+
+[give details in bullet point format]
+
+Use a mix of short and long sentences. Make it punchy and dramatic.`
+		},
+		{
+			command: '/youtube-desc',
+
+			title: 'YouTube Video',
+			content: `Write a 100-word YouTube video description that compels [audience]
+
+to watch a video on [topic]
+
+and mentions the following keywords
+
+[keyword 1]
+
+[keyword 2]
+
+[keyword 3].`
+		},
+		{
+			command: '/seo-meta',
+
+			title: 'SEO Meta',
+			content: `Suggest a meta description for the content above, make it user-friendly and with a call to action, include the keyword [keyword].`
+		},
+		{
+			command: '/eli5',
+
+			title: 'ELI5',
+			content: `You are an expert teacher with the ability to explain complex topics in simpler terms. Explain the concept of [topic] in simple terms, so that my [grade level/subject] class can understand [this concept/specific example]?`
+		},
+		{
+			command: '/emoji-translate',
+
+			title: 'Emoji Translation',
+			content: `You are an emoji expert. Using only emojis, translate the following text to emojis. [insert numbered sentences].`
+		}
+	];
+
+	let filteredPromptCommands = [];
+
+	$: filteredPromptCommands = promptCommands
+		.filter((p) => p.command.includes(prompt))
+		.sort((a, b) => a.title.localeCompare(b.title));
+
+	$: if (prompt) {
+		selectedCommandIdx = 0;
+	}
+
+	export const selectUp = () => {
+		selectedCommandIdx = Math.max(0, selectedCommandIdx - 1);
+	};
+
+	export const selectDown = () => {
+		selectedCommandIdx = Math.min(selectedCommandIdx + 1, filteredPromptCommands.length - 1);
+	};
+
+	const confirmCommand = async (command) => {
+		prompt = command.content;
+
+		const chatInputElement = document.getElementById('chat-textarea');
+
+		await tick();
+
+		chatInputElement.style.height = '';
+		chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
+
+		chatInputElement?.focus();
+
+		await tick();
+
+		const words = findWordIndices(prompt);
+
+		if (words.length > 0) {
+			const word = words.at(0);
+			chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
+		}
+	};
+</script>
+
+{#if filteredPromptCommands.length > 0}
+	<div class="md:px-2 mb-3 text-left w-full">
+		<div class="flex w-full rounded-lg border border-gray-100 dark:border-gray-700">
+			<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-lg text-center">
+				<div class=" text-lg font-medium mt-2">/</div>
+			</div>
+			<div class=" max-h-60 overflow-y-auto bg-white w-full p-2 rounded-r-lg space-y-0.5">
+				{#each filteredPromptCommands as command, commandIdx}
+					<button
+						class=" px-3 py-1.5 rounded-lg w-full text-left {commandIdx === selectedCommandIdx
+							? ' bg-gray-100 selected-command-option-button'
+							: ''}"
+						type="button"
+						on:click={() => {
+							confirmCommand(command);
+						}}
+						on:mousemove={() => {
+							selectedCommandIdx = commandIdx;
+						}}
+						on:focus={() => {}}
+					>
+						<div class=" font-medium text-black">
+							{command.command}
+						</div>
+
+						<div class=" text-xs text-gray-600">
+							{command.title}
+						</div>
+					</button>
+				{/each}
+			</div>
+		</div>
+	</div>
+{/if}

+ 16 - 0
src/lib/utils/index.ts

@@ -111,3 +111,19 @@ export const checkVersion = (required, current) => {
 				caseFirst: 'upper'
 		  }) < 0;
 };
+
+export const findWordIndices = (text) => {
+	const regex = /\[([^\]]+)\]/g;
+	let matches = [];
+	let match;
+
+	while ((match = regex.exec(text)) !== null) {
+		matches.push({
+			word: match[1],
+			startIndex: match.index,
+			endIndex: regex.lastIndex - 1
+		});
+	}
+
+	return matches;
+};