diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index edb01f5..f6dc7a4 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -176,7 +176,7 @@ export const getByConversationPublic = query({ handler: async (ctx, args) => { // First check if the conversation is public const conversation = await ctx.db.get(args.conversation_id); - + if (!conversation || !conversation.public) { return null; } diff --git a/src/lib/backend/convex/user_rules.ts b/src/lib/backend/convex/user_rules.ts index 07d1485..9f8acba 100644 --- a/src/lib/backend/convex/user_rules.ts +++ b/src/lib/backend/convex/user_rules.ts @@ -131,4 +131,4 @@ export const rename = mutation({ name: args.name, }); }, -}); \ No newline at end of file +}); diff --git a/src/lib/components/ui/share-button/index.ts b/src/lib/components/ui/share-button/index.ts index ee003e6..69e41b7 100644 --- a/src/lib/components/ui/share-button/index.ts +++ b/src/lib/components/ui/share-button/index.ts @@ -1 +1 @@ -export { default as ShareButton } from './share-button.svelte'; \ No newline at end of file +export { default as ShareButton } from './share-button.svelte'; diff --git a/src/lib/components/ui/share-button/share-button.svelte b/src/lib/components/ui/share-button/share-button.svelte index aae5ced..080654a 100644 --- a/src/lib/components/ui/share-button/share-button.svelte +++ b/src/lib/components/ui/share-button/share-button.svelte @@ -142,4 +142,3 @@ {/if} - diff --git a/src/lib/components/ui/sidebar/sidebar-sidebar.svelte b/src/lib/components/ui/sidebar/sidebar-sidebar.svelte index acac6a6..d24f83a 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 index 29c51e4..fca1192 100644 --- a/src/lib/utils/rules.ts +++ b/src/lib/utils/rules.ts @@ -1,6 +1,9 @@ -import type { Doc } from "$lib/backend/convex/_generated/dataModel"; +import type { Doc } from '$lib/backend/convex/_generated/dataModel'; -export function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] { +export function parseMessageForRules( + message: string, + rules: Doc<'user_rules'>[] +): Doc<'user_rules'>[] { const matchedRules: Doc<'user_rules'>[] = []; for (const rule of rules) { @@ -11,4 +14,4 @@ export function parseMessageForRules(message: string, rules: Doc<'user_rules'>[] } return matchedRules; -} \ No newline at end of file +} diff --git a/src/routes/chat/[id]/message.svelte b/src/routes/chat/[id]/message.svelte index 07ab0ad..d56deea 100644 --- a/src/routes/chat/[id]/message.svelte +++ b/src/routes/chat/[id]/message.svelte @@ -163,7 +163,7 @@
- import { goto } from '$app/navigation'; - import { page } from '$app/state'; - import { api } from '$lib/backend/convex/_generated/api.js'; - import { type Doc, type Id } from '$lib/backend/convex/_generated/dataModel.js'; - import { useCachedQuery } from '$lib/cache/cached-query.svelte.js'; - import AppSidebar from '$lib/components/app-sidebar.svelte'; - import * as Icons from '$lib/components/icons'; - import { Button } from '$lib/components/ui/button'; - import { ImageModal } from '$lib/components/ui/image-modal'; - import { LightSwitch } from '$lib/components/ui/light-switch/index.js'; - import { ShareButton } from '$lib/components/ui/share-button'; - import * as Sidebar from '$lib/components/ui/sidebar'; - import Tooltip from '$lib/components/ui/tooltip.svelte'; - import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js'; - import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; - import { models } from '$lib/state/models.svelte'; - import { usePrompt } from '$lib/state/prompt.svelte.js'; - import { session } from '$lib/state/session.svelte.js'; - import { settings } from '$lib/state/settings.svelte.js'; - import { Provider } from '$lib/types'; - import { compressImage } from '$lib/utils/image-compression'; - import { supportsImages } from '$lib/utils/model-capabilities'; - import { omit, pick } from '$lib/utils/object.js'; - import { cn } from '$lib/utils/utils.js'; - import { useConvexClient } from 'convex-svelte'; - import { FileUpload, Popover } from 'melt/builders'; - import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed'; - 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'; - import PanelLeftIcon from '~icons/lucide/panel-left'; - import SearchIcon from '~icons/lucide/search'; - import Settings2Icon from '~icons/lucide/settings-2'; - import StopIcon from '~icons/lucide/square'; - import UploadIcon from '~icons/lucide/upload'; - import XIcon from '~icons/lucide/x'; - import { callCancelGeneration } from '../api/cancel-generation/call.js'; - import { callGenerateMessage } from '../api/generate-message/call.js'; - import ModelPicker from './model-picker.svelte'; - import SearchModal from './search-modal.svelte'; - import SparkleIcon from '~icons/lucide/sparkle'; - import { callEnhancePrompt } from '../api/enhance-prompt/call.js'; - import ShinyText from '$lib/components/animations/shiny-text.svelte'; - - const client = useConvexClient(); - - let { children } = $props(); - - let textarea = $state(); - let abortController = $state(null); - - $effect(() => { - client.mutation(api.user_enabled_models.enable_initial, { - session_token: session.current?.session.token ?? '', - }); - }); - - const currentConversationQuery = useCachedQuery(api.conversations.getById, () => ({ - conversation_id: page.params.id as Id<'conversations'>, - session_token: session.current?.session.token ?? '', - })); - - const isGenerating = $derived( - Boolean(currentConversationQuery.data?.generating) || currentConversationQuery.isLoading - ); - - async function stopGeneration() { - if (!page.params.id || !session.current?.session.token) return; - - try { - const result = await callCancelGeneration({ - conversation_id: page.params.id, - session_token: session.current.session.token, - }); - - if (result.isErr()) { - console.error('Failed to cancel generation:', result.error); - } else { - console.log('Generation cancelled:', result.value.cancelled); - } - } catch (error) { - console.error('Error cancelling generation:', error); - } - - // Clear local abort controller if it exists - if (abortController) { - abortController = null; - } - } - - let loading = $state(false); - - let enhancingPrompt = $state(false); - - const textareaDisabled = $derived( - isGenerating || - loading || - (currentConversationQuery.data && - currentConversationQuery.data.user_id !== session.current?.user.id) || - enhancingPrompt - ); - - let error = $state(null); - - async function handleSubmit() { - if (isGenerating) return; - - error = null; - - // TODO: Re-use zod here from server endpoint for better error messages? - if (message.current === '' || !session.current?.user.id || !settings.modelId) return; - - loading = true; - - const imagesCopy = [...selectedImages]; - selectedImages = []; - - try { - const res = await callGenerateMessage({ - message: message.current, - session_token: session.current?.session.token, - conversation_id: page.params.id ?? undefined, - model_id: settings.modelId, - images: imagesCopy.length > 0 ? imagesCopy : undefined, - web_search_enabled: settings.webSearchEnabled, - }); - - if (res.isErr()) { - error = res._unsafeUnwrapErr() ?? 'An unknown error occurred'; - return; - } - - const cid = res.value.conversation_id; - - if (page.params.id !== cid) { - goto(`/chat/${cid}`); - } - } catch (error) { - console.error('Error generating message:', error); - } finally { - loading = false; - message.current = ''; - } - } - - 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 ?? '', - }); - - const autosize = new TextareaAutosize(); - - const message = new PersistedState('prompt', ''); - let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]); - let isUploading = $state(false); - let fileInput = $state(); - let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({ - open: false, - imageUrl: '', - fileName: '', - }); - - usePrompt( - () => message.current, - (v) => (message.current = v) - ); - - models.init(); - - const currentModelSupportsImages = $derived.by(() => { - if (!settings.modelId) return false; - const openRouterModels = models.from(Provider.OpenRouter); - const currentModel = openRouterModels.find((m) => m.id === settings.modelId); - return currentModel ? supportsImages(currentModel) : false; - }); - - const fileUpload = new FileUpload({ - multiple: true, - accept: 'image/*', - maxSize: 10 * 1024 * 1024, // 10MB - }); - - async function handleFileChange(files: File[]) { - if (!files.length || !session.current?.session.token) return; - - isUploading = true; - const uploadedFiles: { url: string; storage_id: string; fileName?: string }[] = []; - - try { - for (const file of files) { - // Skip non-image files - if (!file.type.startsWith('image/')) { - console.warn('Skipping non-image file:', file.name); - continue; - } - - // Compress image to max 1MB - const compressedFile = await compressImage(file, 1024 * 1024); - - // Generate upload URL - const uploadUrl = await client.mutation(api.storage.generateUploadUrl, { - session_token: session.current.session.token, - }); - - // Upload compressed file - const result = await fetch(uploadUrl, { - method: 'POST', - body: compressedFile, - }); - - if (!result.ok) { - throw new Error(`Upload failed: ${result.statusText}`); - } - - const { storageId } = await result.json(); - - // Get the URL for the uploaded file - const url = await client.query(api.storage.getUrl, { - storage_id: storageId, - session_token: session.current.session.token, - }); - - if (url) { - uploadedFiles.push({ url, storage_id: storageId, fileName: file.name }); - } - } - - selectedImages = [...selectedImages, ...uploadedFiles]; - } catch (error) { - console.error('Upload failed:', error); - } finally { - isUploading = false; - } - } - - function removeImage(index: number) { - selectedImages = selectedImages.filter((_, i) => i !== index); - } - - function openImageModal(imageUrl: string, fileName: string) { - imageModal = { - open: true, - imageUrl, - fileName, - }; - } - - $effect(() => { - if (fileUpload.selected.size > 0) { - handleFileChange(Array.from(fileUpload.selected)); - fileUpload.clear(); - } - }); - - const suggestedRules = $derived.by(() => { - if (!rulesQuery.data || rulesQuery.data.length === 0) return; - if (!textarea) return; - - const cursor = textarea.selectionStart; - - const index = message.current.lastIndexOf('@', cursor); - if (index === -1) return; - - const ruleFromCursor = message.current.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({ - floatingConfig: { - computePosition: { placement: 'top' }, - }, - }); - - function completeRule(rule: Doc<'user_rules'>) { - if (!textarea) return; - - const cursor = textarea.selectionStart; - - const index = message.current.lastIndexOf('@', cursor); - if (index === -1) return; - - message.current = - message.current.slice(0, index) + `@${rule.name}` + message.current.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); - - let textareaWrapper = $state(); - const wrapperSize = new ElementSize(() => textareaWrapper); - - let conversationList = $state(); - const scrollState = new ScrollState({ - element: () => conversationList, - }); - - const mounted = new IsMounted(); - - const notAtBottom = new Debounced( - () => (mounted.current ? !scrollState.arrived.bottom : false), - () => (mounted.current ? 250 : 0) - ); - - let searchModalOpen = $state(false); - - function openSearchModal() { - searchModalOpen = true; - } - - let sidebarOpen = $state(false); - - - - - - - {#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} - - -
- -