diff --git a/src/app.css b/src/app.css index c0af892..7fd41c7 100644 --- a/src/app.css +++ b/src/app.css @@ -233,6 +233,18 @@ display: none; /* Chrome, Safari, and Opera */ } +@layer utilities { + .animation-delay-0 { + animation-delay: 0s; + } + .animation-delay-100 { + animation-delay: 0.1s; + } + .animation-delay-200 { + animation-delay: 0.2s; + } +} + @layer components { /* Modal is from DaisyUI */ .modal { diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index e3f0725..cc38ae8 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -34,6 +34,30 @@ export const get = query({ }, }); +export const getById = query({ + args: { + conversation_id: v.id('conversations'), + session_token: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) { + throw new Error('Unauthorized'); + } + + const conversation = await ctx.db.get(args.conversation_id); + + if (!conversation || conversation.user_id !== session.userId) { + throw new Error('Conversation not found or unauthorized'); + } + + return conversation; + }, +}); + export const create = mutation({ args: { session_token: v.string(), @@ -52,6 +76,7 @@ export const create = mutation({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out user_id: session.userId as any, updated_at: Date.now(), + generating: true, }); return res; @@ -81,6 +106,7 @@ export const createAndAddMessage = mutation({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out user_id: session.userId as any, updated_at: Date.now(), + generating: true, }); const messageId = await ctx.runMutation(api.messages.create, { @@ -125,6 +151,34 @@ export const updateTitle = mutation({ }, }); +export const updateGenerating = mutation({ + args: { + conversation_id: v.id('conversations'), + generating: v.boolean(), + session_token: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) { + throw new Error('Unauthorized'); + } + + // Verify the conversation belongs to the user + const conversation = await ctx.db.get(args.conversation_id); + if (!conversation || conversation.user_id !== session.userId) { + throw new Error('Conversation not found or unauthorized'); + } + + await ctx.db.patch(args.conversation_id, { + generating: args.generating, + updated_at: Date.now(), + }); + }, +}); + export const togglePin = mutation({ args: { conversation_id: v.id('conversations'), @@ -177,4 +231,3 @@ export const remove = mutation({ await ctx.db.delete(args.conversation_id); }, }); - diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 68e7938..0c44403 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -50,26 +50,34 @@ export const create = mutation({ throw new Error('Unauthorized'); } - const messages = await ctx.runQuery(api.messages.getAllFromConversation, { - conversation_id: args.conversation_id, - session_token: args.session_token, - }); + // I think this just slows us down - const lastMessage = messages[messages.length - 1]; + // const messages = await ctx.runQuery(api.messages.getAllFromConversation, { + // conversation_id: args.conversation_id, + // session_token: args.session_token, + // }); - if (lastMessage?.role === args.role) { - throw new Error('Last message has the same role, forbidden'); - } + // const lastMessage = messages[messages.length - 1]; - const id = await ctx.db.insert('messages', { - conversation_id: args.conversation_id, - content: args.content, - role: args.role, - // Optional, coming from SK API route - model_id: args.model_id, - provider: args.provider, - token_count: args.token_count, - }); + // if (lastMessage?.role === args.role) { + // throw new Error('Last message has the same role, forbidden'); + // } + + const [id] = await Promise.all([ + ctx.db.insert('messages', { + conversation_id: args.conversation_id, + content: args.content, + role: args.role, + // Optional, coming from SK API route + model_id: args.model_id, + provider: args.provider, + token_count: args.token_count, + }), + ctx.db.patch(args.conversation_id as Id<'conversations'>, { + generating: true, + updated_at: Date.now(), + }), + ]); return id; }, diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index 66109d6..618e629 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -46,6 +46,7 @@ export default defineSchema({ title: v.string(), updated_at: v.optional(v.number()), pinned: v.optional(v.boolean()), + generating: v.boolean(), }).index('by_user', ['user_id']), messages: defineTable({ conversation_id: v.string(), diff --git a/src/lib/components/ui/copy-button/copy-button.svelte b/src/lib/components/ui/copy-button/copy-button.svelte new file mode 100644 index 0000000..0ace4d2 --- /dev/null +++ b/src/lib/components/ui/copy-button/copy-button.svelte @@ -0,0 +1,73 @@ + + + + + diff --git a/src/lib/components/ui/copy-button/index.ts b/src/lib/components/ui/copy-button/index.ts new file mode 100644 index 0000000..06ff799 --- /dev/null +++ b/src/lib/components/ui/copy-button/index.ts @@ -0,0 +1,7 @@ +/* + Installed from @ieedan/shadcn-svelte-extras +*/ + +import CopyButton from './copy-button.svelte'; + +export { CopyButton }; diff --git a/src/lib/components/ui/copy-button/types.ts b/src/lib/components/ui/copy-button/types.ts new file mode 100644 index 0000000..f21fff5 --- /dev/null +++ b/src/lib/components/ui/copy-button/types.ts @@ -0,0 +1,20 @@ +/* + Installed from @ieedan/shadcn-svelte-extras +*/ + +import type { Snippet } from 'svelte'; +import type { ButtonPropsWithoutHTML } from '$lib/components/ui/button'; +import type { UseClipboard } from '$lib/hooks/use-clipboard.svelte'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type CopyButtonPropsWithoutHTML = Pick & { + ref?: HTMLButtonElement | null; + text: string; + icon?: Snippet<[]>; + animationDuration?: number; + onCopy?: (status: UseClipboard['status']) => void; + children?: Snippet<[]>; +}; + +export type CopyButtonProps = CopyButtonPropsWithoutHTML & + Omit, 'children'>; diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 106d948..10910f6 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -2,13 +2,14 @@ import { cn } from '$lib/utils/utils'; import type { HTMLInputAttributes } from 'svelte/elements'; - let { class: className, ...restProps }: HTMLInputAttributes = $props(); + let { value = $bindable(''), class: className, ...restProps }: HTMLInputAttributes = $props(); diff --git a/src/lib/hooks/use-clipboard.svelte.ts b/src/lib/hooks/use-clipboard.svelte.ts new file mode 100644 index 0000000..01c12ea --- /dev/null +++ b/src/lib/hooks/use-clipboard.svelte.ts @@ -0,0 +1,87 @@ +/* + Installed from @ieedan/shadcn-svelte-extras +*/ + +type Options = { + /** The time before the copied status is reset. */ + delay: number; +}; + +/** Use this hook to copy text to the clipboard and show a copied state. + * + * ## Usage + * ```svelte + * + * + * + * ``` + * + */ +export class UseClipboard { + #copiedStatus = $state<'success' | 'failure'>(); + private delay: number; + private timeout: ReturnType | undefined = undefined; + + constructor({ delay = 500 }: Partial = {}) { + this.delay = delay; + } + + /** Copies the given text to the users clipboard. + * + * ## Usage + * ```ts + * clipboard.copy('Hello, World!'); + * ``` + * + * @param text + * @returns + */ + async copy(text: string) { + if (this.timeout) { + this.#copiedStatus = undefined; + clearTimeout(this.timeout); + } + + try { + await navigator.clipboard.writeText(text); + + this.#copiedStatus = 'success'; + + this.timeout = setTimeout(() => { + this.#copiedStatus = undefined; + }, this.delay); + } catch { + // an error can occur when not in the browser or if the user hasn't given clipboard access + this.#copiedStatus = 'failure'; + + this.timeout = setTimeout(() => { + this.#copiedStatus = undefined; + }, this.delay); + } + + return this.#copiedStatus; + } + + /** true when the user has just copied to the clipboard. */ + get copied() { + return this.#copiedStatus === 'success'; + } + + /** Indicates whether a copy has occurred + * and gives a status of either `success` or `failure`. */ + get status() { + return this.#copiedStatus; + } +} diff --git a/src/routes/account/customization/+page.svelte b/src/routes/account/customization/+page.svelte index a281cd0..2dafd0d 100644 --- a/src/routes/account/customization/+page.svelte +++ b/src/routes/account/customization/+page.svelte @@ -29,14 +29,10 @@ async function submitNewRule(e: SubmitEvent) { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); - const name = formData.get('name') as string; const attach = formData.get('attach') as 'always' | 'manual'; const rule = formData.get('rule') as string; - if (rule === '' || !rule) return; - - // cannot create rule with the same name - if (userRulesQuery.data?.findIndex((r) => r.name === name) !== -1) return; + if (rule === '' || !rule || ruleNameExists) return; creatingRule = true; @@ -47,10 +43,15 @@ session_token: session.current?.session.token ?? '', }); - newRuleCollapsible.open = false; + newRuleCollapsible.open = false; + name = ''; creatingRule = false; } + + let name = $state(''); + + const ruleNameExists = $derived(userRulesQuery.data?.findIndex((r) => r.name === name) !== -1); @@ -91,7 +92,14 @@
- +
diff --git a/src/routes/api/generate-message/+server.ts b/src/routes/api/generate-message/+server.ts index f256739..e9f3b8b 100644 --- a/src/routes/api/generate-message/+server.ts +++ b/src/routes/api/generate-message/+server.ts @@ -10,6 +10,7 @@ import OpenAI from 'openai'; import { waitUntil } from '@vercel/functions'; import { z } from 'zod/v4'; +import type { ChatCompletionSystemMessageParam } from 'openai/resources'; // Set to true to enable debug logging const ENABLE_LOGGING = true; @@ -46,7 +47,7 @@ async function generateConversationTitle({ session, startTime, keyResultPromise, - userMessage + userMessage, }: { conversationId: string; session: SessionObj; @@ -57,7 +58,7 @@ async function generateConversationTitle({ log('Starting conversation title generation', startTime); const keyResult = await keyResultPromise; - + if (keyResult.isErr()) { log(`Title generation: API key error: ${keyResult.error}`, startTime); return; @@ -83,8 +84,8 @@ async function generateConversationTitle({ } const conversations = conversationResult.value; - const conversation = conversations.find(c => c._id === conversationId); - + const conversation = conversations.find((c) => c._id === conversationId); + if (!conversation || !conversation.title.includes('Untitled')) { log('Title generation: Conversation not found or already has custom title', startTime); return; @@ -152,16 +153,18 @@ async function generateAIResponse({ startTime, modelResultPromise, keyResultPromise, + rulesResultPromise, }: { conversationId: string; session: SessionObj; startTime: number; keyResultPromise: ResultAsync; modelResultPromise: ResultAsync | null, string>; + rulesResultPromise: ResultAsync[], string>; }) { log('Starting AI response generation in background', startTime); - const [modelResult, keyResult, messagesQueryResult] = await Promise.all([ + const [modelResult, keyResult, messagesQueryResult, rulesResult] = await Promise.all([ modelResultPromise, keyResultPromise, ResultAsync.fromPromise( @@ -171,6 +174,7 @@ async function generateAIResponse({ }), (e) => `Failed to get messages: ${e}` ), + rulesResultPromise, ]); if (modelResult.isErr()) { @@ -209,6 +213,35 @@ async function generateAIResponse({ log('Background: API key retrieved successfully', startTime); + if (rulesResult.isErr()) { + log(`Background rules query failed: ${rulesResult.error}`, startTime); + return; + } + + const userMessage = messages[messages.length - 1]; + + if (!userMessage) { + log('Background: No user message found', startTime); + return; + } + + const attachedRules = [ + ...rulesResult.value.filter((r) => r.attach === 'always'), + ...parseMessageForRules( + userMessage.content, + rulesResult.value.filter((r) => r.attach === 'manual') + ), + ]; + + log(`Background: ${attachedRules.length} rules attached`, startTime); + + const systemMessage: ChatCompletionSystemMessageParam = { + role: 'system', + content: `The user may have mentioned one or more rules to follow with the @ syntax. Please follow these rules. +Rules to follow: +${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, + }; + const openai = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey: key, @@ -217,7 +250,7 @@ async function generateAIResponse({ const streamResult = await ResultAsync.fromPromise( openai.chat.completions.create({ model: model.model_id, - messages: messages.map((m) => ({ role: m.role, content: m.content })), + messages: [...messages.map((m) => ({ role: m.role, content: m.content })), systemMessage], max_tokens: 1000, temperature: 0.7, stream: true, @@ -283,6 +316,21 @@ async function generateAIResponse({ startTime ); + const updateGeneratingResult = await ResultAsync.fromPromise( + client.mutation(api.conversations.updateGenerating, { + conversation_id: conversationId as Id<'conversations'>, + generating: false, + session_token: session.token, + }), + (e) => `Failed to update generating status: ${e}` + ); + + if (updateGeneratingResult.isErr()) { + log(`Background generating status update failed: ${updateGeneratingResult.error}`, startTime); + return; + } + + log('Background: Generating status updated to false', startTime); } catch (error) { log(`Background stream processing error: ${error}`, startTime); } @@ -348,6 +396,13 @@ export const POST: RequestHandler = async ({ request }) => { (e) => `Failed to get API key: ${e}` ); + const rulesResultPromise = ResultAsync.fromPromise( + client.query(api.user_rules.all, { + session_token: session.token, + }), + (e) => `Failed to get rules: ${e}` + ); + log('Session authenticated successfully', startTime); let conversationId = args.conversation_id; @@ -376,7 +431,7 @@ export const POST: RequestHandler = async ({ request }) => { session, startTime, keyResultPromise, - userMessage: args.message + userMessage: args.message, }).catch((error) => { log(`Background title generation error: ${error}`, startTime); }) @@ -410,6 +465,7 @@ export const POST: RequestHandler = async ({ request }) => { startTime, modelResultPromise, keyResultPromise, + rulesResultPromise, }).catch((error) => { log(`Background AI response generation error: ${error}`, startTime); }) @@ -419,15 +475,15 @@ 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'>[] = []; +function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] { + const matchedRules: Doc<'user_rules'>[] = []; -// for (const rule of rules) { -// const match = message.indexOf(`@${rule.name} `); -// if (match === -1) continue; + for (const rule of rules) { + const match = message.match(new RegExp(`@${rule.name}(\\s|$)`)); + if (!match) continue; -// matchedRules.push(rule); -// } + matchedRules.push(rule); + } -// return matchedRules; -// } + return matchedRules; +} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 150daf2..17b995b 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -20,8 +20,12 @@ import { type Doc, type Id } from '$lib/backend/convex/_generated/dataModel.js'; import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; import Tooltip from '$lib/components/ui/tooltip.svelte'; + import { Popover } from 'melt/builders'; import { useConvexClient } from 'convex-svelte'; import { callModal } from '$lib/components/ui/modal/global-modal.svelte'; + import { ElementSize } from 'runed'; + import LoaderCircleIcon from '~icons/lucide/loader-circle'; + import { cn } from '$lib/utils/utils.js'; const client = useConvexClient(); @@ -56,6 +60,10 @@ session_token: session.current?.session.token ?? '', }); + const rulesQuery = useCachedQuery(api.user_rules.all, { + session_token: session.current?.session.token ?? '', + }); + const _autosize = new TextareaAutosize(); function groupConversationsByTime(conversations: Doc<'conversations'>[]) { @@ -143,6 +151,106 @@ { key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth }, { key: 'older', label: 'Older', conversations: groupedConversations.older }, ]); + + let message = $state(''); + + const suggestedRules = $derived.by(() => { + if (!rulesQuery.data || rulesQuery.data.length === 0) return; + if (!textarea) return; + + const cursor = textarea.selectionStart; + + const index = message.lastIndexOf('@', cursor); + if (index === -1) return; + + const ruleFromCursor = message.slice(index + 1, cursor); + + const suggestions: Doc<'user_rules'>[] = []; + + for (const rule of rulesQuery.data) { + // on a match, don't show any suggestions + if (rule.name === ruleFromCursor) return; + + if (rule.name.toLowerCase().startsWith(ruleFromCursor.toLowerCase())) { + suggestions.push(rule); + } + } + + return suggestions.length > 0 ? suggestions : undefined; + }); + + const popover = new Popover(); + + function completeRule(rule: Doc<'user_rules'>) { + if (!textarea) return; + + const cursor = textarea.selectionStart; + + const index = message.lastIndexOf('@', cursor); + if (index === -1) return; + + message = message.slice(0, index) + `@${rule.name}` + message.slice(cursor); + textarea.selectionStart = index + rule.name.length + 1; + textarea.selectionEnd = index + rule.name.length + 1; + + popover.open = false; + } + + function completeSelectedRule() { + if (!suggestedRules) return; + + const rules = Array.from(ruleList.querySelectorAll('[data-list-item]')); + + const activeIndex = rules.findIndex((r) => r.getAttribute('data-active') === 'true'); + if (activeIndex === -1) return; + + const rule = suggestedRules[activeIndex]; + + if (!rule) return; + + completeRule(rule); + } + + let ruleList = $state(null!); + + function handleKeyboardNavigation(direction: 'up' | 'down') { + if (!suggestedRules) return; + + const rules = Array.from(ruleList.querySelectorAll('[data-list-item]')); + + let activeIndex = rules?.findIndex((r) => r.getAttribute('data-active') === 'true'); + if (activeIndex === -1) { + if (!suggestedRules[0]) return; + + rules[0]?.setAttribute('data-active', 'true'); + return; + } + + // don't loop + if (direction === 'up' && activeIndex === 0) { + return; + } + // don't loop + if (direction === 'down' && activeIndex === suggestedRules.length - 1) { + return; + } + + rules[activeIndex]?.setAttribute('data-active', 'false'); + + if (direction === 'up') { + const newIndex = activeIndex - 1; + if (!suggestedRules[newIndex]) return; + + rules[newIndex]?.setAttribute('data-active', 'true'); + } else { + const newIndex = activeIndex + 1; + if (!suggestedRules[newIndex]) return; + + rules[newIndex]?.setAttribute('data-active', 'true'); + } + } + + const textareaSize = new ElementSize(() => textarea); @@ -180,16 +288,26 @@
{#each group.conversations as conversation (conversation._id)} {@const isActive = page.params.id === conversation._id} - -
-

+ +

+

{conversation.title}

+
+ {#if conversation.generating} +
+ +
+ {/if} +
+ {#if suggestedRules} +
+
+ {#each suggestedRules as rule, i (rule._id)} + + {/each} +
+
+ {/if} diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index 46997c0..9cecd85 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -1,25 +1,39 @@
{#each messages.data ?? [] as message (message._id)} - {#if message.role === 'user'} -
- {message.content} -
- {:else if message.role === 'assistant'} -
- {message.content} -
- {/if} + {/each} + {#if conversation.data?.generating && !lastMessageHasContent} + + {/if}
diff --git a/src/routes/chat/[id]/loading-dots.svelte b/src/routes/chat/[id]/loading-dots.svelte new file mode 100644 index 0000000..604390c --- /dev/null +++ b/src/routes/chat/[id]/loading-dots.svelte @@ -0,0 +1,11 @@ +
+
+
+
+
\ No newline at end of file diff --git a/src/routes/chat/[id]/message.svelte b/src/routes/chat/[id]/message.svelte new file mode 100644 index 0000000..cc63f50 --- /dev/null +++ b/src/routes/chat/[id]/message.svelte @@ -0,0 +1,37 @@ + + +{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0)} +
+
+ {message.content} +
+
+ +
+
+{/if}