From 13b3449d82d06b4f259fe4c379fd7fec24e186cc Mon Sep 17 00:00:00 2001 From: Aidan Bleser <117548273+ieedan@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:14:41 -0500 Subject: [PATCH] WIP feat: Enhance prompt button (#31) --- src/app.css | 13 + .../components/animations/shiny-text.svelte | 31 + .../ui/sidebar/sidebar-sidebar.svelte | 2 +- src/lib/utils/rules.ts | 14 + src/routes/api/enhance-prompt/+server.ts | 132 +++ src/routes/api/enhance-prompt/call.ts | 31 + src/routes/api/generate-message/+server.ts | 15 +- src/routes/chat/+layout.svelte | 124 ++- src/routes/chat/+page.svelte | 4 + src/routes/chat/layout copy.svelte | 774 ++++++++++++++++++ src/routes/chat/search-modal.svelte | 2 +- vite.config.ts | 6 +- 12 files changed, 1102 insertions(+), 46 deletions(-) create mode 100644 src/lib/components/animations/shiny-text.svelte create mode 100644 src/lib/utils/rules.ts create mode 100644 src/routes/api/enhance-prompt/+server.ts create mode 100644 src/routes/api/enhance-prompt/call.ts create mode 100644 src/routes/chat/layout copy.svelte diff --git a/src/app.css b/src/app.css index 7e86435..5f5f1ca 100644 --- a/src/app.css +++ b/src/app.css @@ -147,6 +147,9 @@ --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); + + /* For shiny text */ + --animate-shimmer: shimmer 1.5s infinite; } @theme inline { @@ -584,3 +587,13 @@ pre button.copy { opacity: 1; } } + +/* For shiny text */ +@keyframes shimmer { + 0% { + background-position: calc(-100% - var(--shimmer-width)) 0; + } + 100% { + background-position: calc(100% + var(--shimmer-width)) 0; + } +} diff --git a/src/lib/components/animations/shiny-text.svelte b/src/lib/components/animations/shiny-text.svelte new file mode 100644 index 0000000..5dcd114 --- /dev/null +++ b/src/lib/components/animations/shiny-text.svelte @@ -0,0 +1,31 @@ + + + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/sidebar/sidebar-sidebar.svelte b/src/lib/components/ui/sidebar/sidebar-sidebar.svelte index d24f83a..acac6a6 100644 --- a/src/lib/components/ui/sidebar/sidebar-sidebar.svelte +++ b/src/lib/components/ui/sidebar/sidebar-sidebar.svelte @@ -12,7 +12,7 @@
diff --git a/src/lib/utils/rules.ts b/src/lib/utils/rules.ts new file mode 100644 index 0000000..29c51e4 --- /dev/null +++ b/src/lib/utils/rules.ts @@ -0,0 +1,14 @@ +import type { Doc } from "$lib/backend/convex/_generated/dataModel"; + +export function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] { + const matchedRules: Doc<'user_rules'>[] = []; + + for (const rule of rules) { + const match = message.match(new RegExp(`@${rule.name}(\\s|$)`)); + if (!match) continue; + + matchedRules.push(rule); + } + + return matchedRules; +} \ No newline at end of file diff --git a/src/routes/api/enhance-prompt/+server.ts b/src/routes/api/enhance-prompt/+server.ts new file mode 100644 index 0000000..b93c684 --- /dev/null +++ b/src/routes/api/enhance-prompt/+server.ts @@ -0,0 +1,132 @@ +import { error, json, type RequestHandler } from '@sveltejs/kit'; +import { ResultAsync } from 'neverthrow'; +import { z } from 'zod/v4'; +import { OPENROUTER_FREE_KEY } from '$env/static/private'; +import { OpenAI } from 'openai'; +import { ConvexHttpClient } from 'convex/browser'; +import { PUBLIC_CONVEX_URL } from '$env/static/public'; +import { api } from '$lib/backend/convex/_generated/api'; +import { parseMessageForRules } from '$lib/utils/rules'; +import { Provider } from '$lib/types'; + +const FREE_MODEL = 'google/gemma-3-27b-it'; + +const reqBodySchema = z.object({ + prompt: z.string(), +}); + +const client = new ConvexHttpClient(PUBLIC_CONVEX_URL); + +export type EnhancePromptRequestBody = z.infer; + +export type EnhancePromptResponse = { + ok: true; + enhanced_prompt: string; +}; + +function response({ enhanced_prompt }: { enhanced_prompt: string }) { + return json({ + ok: true, + enhanced_prompt, + }); +} + +export const POST: RequestHandler = async ({ request, locals }) => { + const bodyResult = await ResultAsync.fromPromise( + request.json(), + () => 'Failed to parse request body' + ); + + if (bodyResult.isErr()) { + return error(400, 'Failed to parse request body'); + } + + const parsed = reqBodySchema.safeParse(bodyResult.value); + if (!parsed.success) { + return error(400, parsed.error); + } + const args = parsed.data; + + const session = await locals.auth(); + + if (!session) { + return error(401, 'You must be logged in to enhance a prompt'); + } + + const [rulesResult, keyResult] = await Promise.all([ + ResultAsync.fromPromise( + client.query(api.user_rules.all, { + session_token: session.session.token, + }), + (e) => `Failed to get rules: ${e}` + ), + ResultAsync.fromPromise( + client.query(api.user_keys.get, { + provider: Provider.OpenRouter, + session_token: session.session.token, + }), + (e) => `Failed to get API key: ${e}` + ), + ]); + + if (rulesResult.isErr()) { + return error(500, 'Failed to get rules'); + } + + if (keyResult.isErr()) { + return error(500, 'Failed to get key'); + } + + const mentionedRules = parseMessageForRules( + args.prompt, + rulesResult.value.filter((r) => r.attach === 'manual') + ); + + const openai = new OpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey: keyResult.value ?? OPENROUTER_FREE_KEY, + }); + + const enhancePrompt = ` +Enhance prompt below (wrapped in tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM. +You can do this by fixing spelling/grammatical errors, clarifying details, and removing unnecessary wording where possible. +Only return the enhanced prompt, nothing else. Do NOT wrap it in quotes, do NOT use markdown. +Do NOT respond to the prompt only optimize it so that another LLM can understand it better. +Do NOT remove context that may be necessary for the prompt to be understood. + +${ + mentionedRules.length > 0 + ? `The user has mentioned rules with the @ syntax. Make sure to include the rules in the final prompt even if you just add them to the end. +Mentioned rules: ${mentionedRules.map((r) => `@${r.name}`).join(', ')}` + : '' +} + + +${args.prompt} + +`; + + const enhancedResult = await ResultAsync.fromPromise( + openai.chat.completions.create({ + model: FREE_MODEL, + messages: [{ role: 'user', content: enhancePrompt }], + temperature: 0.5, + }), + (e) => `Enhance prompt API call failed: ${e}` + ); + + if (enhancedResult.isErr()) { + return error(500, 'error enhancing the prompt'); + } + + const enhancedResponse = enhancedResult.value; + const enhanced = enhancedResponse.choices[0]?.message?.content; + + if (!enhanced) { + return error(500, 'error enhancing the prompt'); + } + + return response({ + enhanced_prompt: enhanced, + }); +}; diff --git a/src/routes/api/enhance-prompt/call.ts b/src/routes/api/enhance-prompt/call.ts new file mode 100644 index 0000000..5aa41ac --- /dev/null +++ b/src/routes/api/enhance-prompt/call.ts @@ -0,0 +1,31 @@ +import { ResultAsync } from 'neverthrow'; +import type { EnhancePromptRequestBody, EnhancePromptResponse } from './+server'; + +export async function callEnhancePrompt( + args: EnhancePromptRequestBody, + { signal }: { signal?: AbortSignal } = {} +) { + const res = ResultAsync.fromPromise( + (async () => { + const res = await fetch('/api/enhance-prompt', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(args), + signal, + }); + + if (!res.ok) { + const { message } = await res.json(); + + throw new Error(message as string); + } + + return res.json() as Promise; + })(), + (e) => `${e}` + ); + + return res; +} diff --git a/src/routes/api/generate-message/+server.ts b/src/routes/api/generate-message/+server.ts index 340be0c..df1c354 100644 --- a/src/routes/api/generate-message/+server.ts +++ b/src/routes/api/generate-message/+server.ts @@ -13,6 +13,7 @@ import { z } from 'zod/v4'; import { generationAbortControllers } from './cache.js'; import { md } from '$lib/utils/markdown-it.js'; import * as array from '$lib/utils/array'; +import { parseMessageForRules } from '$lib/utils/rules.js'; // Set to true to enable debug logging const ENABLE_LOGGING = true; @@ -154,7 +155,6 @@ If its a simple hi, just name it "Greeting" or something like that. }), (e) => `Failed to update conversation title: ${e}` ); - t; if (updateResult.isErr()) { log(`Title generation: Failed to update title: ${updateResult.error}`, startTime); @@ -797,19 +797,6 @@ export const POST: RequestHandler = async ({ request }) => { return response({ ok: true, conversation_id: conversationId }); }; -function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] { - const matchedRules: Doc<'user_rules'>[] = []; - - for (const rule of rules) { - const match = message.match(new RegExp(`@${rule.name}(\\s|$)`)); - if (!match) continue; - - matchedRules.push(rule); - } - - return matchedRules; -} - async function getGenerationStats( generationId: string, token: string diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 635f391..fc4b59d 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -26,7 +26,7 @@ import { useConvexClient } from 'convex-svelte'; import { FileUpload, Popover } from 'melt/builders'; import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed'; - import { fade } from 'svelte/transition'; + import { fade, scale } from 'svelte/transition'; import SendIcon from '~icons/lucide/arrow-up'; import ChevronDownIcon from '~icons/lucide/chevron-down'; import ImageIcon from '~icons/lucide/image'; @@ -42,6 +42,9 @@ import SearchModal from './search-modal.svelte'; import { shortcut } from '$lib/actions/shortcut.svelte.js'; import { mergeAttrs } from 'melt'; + import { callEnhancePrompt } from '../api/enhance-prompt/call.js'; + import ShinyText from '$lib/components/animations/shiny-text.svelte'; + import SparkleIcon from '~icons/lucide/sparkle'; const client = useConvexClient(); @@ -91,11 +94,14 @@ let loading = $state(false); + let enhancingPrompt = $state(false); + const textareaDisabled = $derived( isGenerating || loading || (currentConversationQuery.data && - currentConversationQuery.data.user_id !== session.current?.user.id) + currentConversationQuery.data.user_id !== session.current?.user.id) || + enhancingPrompt ); let error = $state(null); @@ -141,6 +147,43 @@ } } + let abortEnhance: AbortController | null = $state(null); + + async function enhancePrompt() { + if (!session.current?.session.token) return; + + enhancingPrompt = true; + + abortEnhance = new AbortController(); + + const res = await callEnhancePrompt( + { + prompt: message.current, + }, + { + signal: abortEnhance.signal, + } + ); + + if (res.isErr()) { + const e = res.error; + + if (e.toLowerCase().includes('aborterror')) { + enhancingPrompt = false; + return; + } + + error = res._unsafeUnwrapErr() ?? 'An unknown error occurred while enhancing the prompt'; + + enhancingPrompt = false; + return; + } + + message.current = res.value.enhanced_prompt; + + enhancingPrompt = false; + } + const rulesQuery = useCachedQuery(api.user_rules.all, { session_token: session.current?.session.token ?? '', }); @@ -651,36 +694,63 @@ {isGenerating ? 'Stop generation' : 'Send message'}
-
+
0} /> - - {#if currentModelSupportsImages} +
- {/if} + {#if currentModelSupportsImages} + + {/if} + {#if session.current !== null && message.current.trim() !== ''} + + {/if} +
diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte index d0a74f8..e643da6 100644 --- a/src/routes/chat/+page.svelte +++ b/src/routes/chat/+page.svelte @@ -71,6 +71,10 @@ const prompt = usePrompt(); + + New Chat | thom.chat + +
{#if prompt.current.length === 0 && openRouterKeyQuery.data}
diff --git a/src/routes/chat/layout copy.svelte b/src/routes/chat/layout copy.svelte new file mode 100644 index 0000000..3176b36 --- /dev/null +++ b/src/routes/chat/layout copy.svelte @@ -0,0 +1,774 @@ + + + + + + + {#if !sidebarOpen} + +
+ + {#snippet trigger(tooltip)} + + + + {/snippet} + Toggle Sidebar ({cmdOrCtrl} + B) + +
+ {/if} + + +
+ {#if page.params.id && currentConversationQuery.data} + } /> + {/if} + + {#snippet trigger(tooltip)} + + {/snippet} + Search ({cmdOrCtrl} + K) + + + {#snippet trigger(tooltip)} + + {/snippet} + Settings + + +
+
+
+
+ {@render children()} +
+ +
+ +
+
+
{ + e.preventDefault(); + handleSubmit(); + }} + > + {#if error} +
+
+ {error} +
+
+ {/if} + {#if suggestedRules} +
+
+ {#each suggestedRules as rule, i (rule._id)} + + {/each} +
+
+ {/if} +
+ {#if selectedImages.length > 0} +
+ {#each selectedImages as image, index (image.storage_id)} +
+ + +
+ {/each} +
+ {/if} +
+ + + + +
+
+
+ + {#snippet trigger(tooltip)} + + {/snippet} + {isGenerating ? 'Stop generation' : 'Send message'} + +
+
+ 0} /> +
+ + {#if currentModelSupportsImages} + + {/if} + {#if session.current !== null && message.current.trim() !== ''} + + {/if} +
+
+
+
+
+
+
+ + + +
+
+ + {#if fileUpload.isDragging && currentModelSupportsImages} +
+
+ +

Add image

+

Drop an image here to attach it to your message.

+
+
+ {/if} + + +
+ + diff --git a/src/routes/chat/search-modal.svelte b/src/routes/chat/search-modal.svelte index 20c7af6..1e8ec68 100644 --- a/src/routes/chat/search-modal.svelte +++ b/src/routes/chat/search-modal.svelte @@ -77,7 +77,7 @@ } - (open = true) }]} /> + (open = true) }} />
diff --git a/vite.config.ts b/vite.config.ts index e757a03..49a17f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ compiler: 'svelte', }), ], + server: { + allowedHosts: isDev ? true : undefined, + }, test: { projects: [ { @@ -39,7 +42,4 @@ export default defineConfig({ }, ], }, - server: { - allowedHosts: isDev ? true : undefined, - }, });