diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index 8677472..0947b43 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -37,10 +37,12 @@ export const get = query({ export const getById = query({ args: { - conversation_id: v.id('conversations'), + conversation_id: v.optional(v.id('conversations')), session_token: v.string(), }, handler: async (ctx, args) => { + if (!args.conversation_id) return null; + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { session_token: args.session_token, }); @@ -90,11 +92,15 @@ export const createAndAddMessage = mutation({ content_html: v.optional(v.string()), role: messageRoleValidator, session_token: v.string(), - images: v.optional(v.array(v.object({ - url: v.string(), - storage_id: v.string(), - fileName: v.optional(v.string()), - }))), + images: v.optional( + v.array( + v.object({ + url: v.string(), + storage_id: v.string(), + fileName: v.optional(v.string()), + }) + ) + ), }, handler: async ( ctx, diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 32b15a8..4d41cc7 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -42,11 +42,15 @@ export const create = mutation({ provider: v.optional(providerValidator), token_count: v.optional(v.number()), // Optional image attachments - images: v.optional(v.array(v.object({ - url: v.string(), - storage_id: v.string(), - fileName: v.optional(v.string()), - }))), + images: v.optional( + v.array( + v.object({ + url: v.string(), + storage_id: v.string(), + fileName: v.optional(v.string()), + }) + ) + ), }, handler: async (ctx, args): Promise> => { const session = await ctx.runQuery(api.betterAuth.publicGetSession, { diff --git a/src/lib/backend/convex/storage.ts b/src/lib/backend/convex/storage.ts index 6b14d40..db49350 100644 --- a/src/lib/backend/convex/storage.ts +++ b/src/lib/backend/convex/storage.ts @@ -55,4 +55,3 @@ export const deleteFile = mutation({ await ctx.storage.delete(args.storage_id); }, }); - diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte new file mode 100644 index 0000000..59eb886 --- /dev/null +++ b/src/lib/components/app-sidebar.svelte @@ -0,0 +1,253 @@ + + + +
+ Thom.chat +
+
+ + {#snippet trigger(tooltip)} + + New Chat + + {/snippet} + {cmdOrCtrl} + Shift + O + +
+
+
+
+ {#each templateConversations as group, index (group.key)} + {@const IconComponent = group.icon} + {#if group.conversations.length > 0} +
0}> +

+ {#if IconComponent} + + {/if} + {group.label} +

+
+ {#each group.conversations as conversation (conversation._id)} + {@const isActive = page.params.id === conversation._id} + +
+

+ {conversation.title} +

+
+ {#if conversation.generating} +
+ +
+ {/if} +
+
+ + {#snippet trigger(tooltip)} + + {/snippet} + {conversation.pinned ? 'Unpin thread' : 'Pin thread'} + + + {#snippet trigger(tooltip)} + + {/snippet} + Delete thread + +
+
+
+ {/each} + {/if} + {/each} +
+
+
+
+ {#if page.data.session !== null} + + {:else} + + {/if} +
+
diff --git a/src/lib/components/ui/image-modal/index.ts b/src/lib/components/ui/image-modal/index.ts index 0a335d0..f262f61 100644 --- a/src/lib/components/ui/image-modal/index.ts +++ b/src/lib/components/ui/image-modal/index.ts @@ -1 +1 @@ -export { default as ImageModal } from './image-modal.svelte'; \ No newline at end of file +export { default as ImageModal } from './image-modal.svelte'; diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts index 4166cc4..ae66ade 100644 --- a/src/lib/components/ui/sidebar/index.ts +++ b/src/lib/components/ui/sidebar/index.ts @@ -2,5 +2,6 @@ import Root from './sidebar.svelte'; import Sidebar from './sidebar-sidebar.svelte'; import Inset from './sidebar-inset.svelte'; import Trigger from './sidebar-trigger.svelte'; +import { useSidebarControls } from './sidebar.svelte.js'; -export { Root, Sidebar, Inset, Trigger }; +export { Root, Sidebar, Inset, Trigger, useSidebarControls }; diff --git a/src/lib/components/ui/sidebar/sidebar.svelte.ts b/src/lib/components/ui/sidebar/sidebar.svelte.ts index d4ac4cb..7c85f44 100644 --- a/src/lib/components/ui/sidebar/sidebar.svelte.ts +++ b/src/lib/components/ui/sidebar/sidebar.svelte.ts @@ -19,6 +19,12 @@ export class SidebarRootState { this.open = !this.open; } } + + closeMobile() { + if (this.isMobile.current) { + this.openMobile = false; + } + } } export class SidebarTriggerState { @@ -35,6 +41,16 @@ export class SidebarSidebarState { constructor(readonly root: SidebarRootState) {} } +export class SidebarControlState { + constructor(readonly root: SidebarRootState) { + this.closeMobile = this.closeMobile.bind(this); + } + + closeMobile() { + this.root.closeMobile(); + } +} + export const ctx = new Context('sidebar-root-context'); export function useSidebar() { @@ -48,3 +64,7 @@ export function useSidebarTrigger() { export function useSidebarSidebar() { return new SidebarSidebarState(ctx.get()); } + +export function useSidebarControls() { + return new SidebarControlState(ctx.get()); +} diff --git a/src/lib/utils/array.ts b/src/lib/utils/array.ts index bd3aaee..47409fb 100644 --- a/src/lib/utils/array.ts +++ b/src/lib/utils/array.ts @@ -27,6 +27,16 @@ export function fromMap(map: Map, fn: (key: K, value: V) => T): T return items; } +export function fromRecord(map: Record, fn: (key: string, value: V) => T): T[] { + const items: T[] = []; + + for (const [key, value] of Object.entries(map)) { + items.push(fn(key, value)); + } + + return items; +} + /** Calculates the sum of all elements in the array based on the provided function. * * @param arr Array of items to be summed. diff --git a/src/lib/utils/image-compression.ts b/src/lib/utils/image-compression.ts index d48e223..d984eed 100644 --- a/src/lib/utils/image-compression.ts +++ b/src/lib/utils/image-compression.ts @@ -70,4 +70,4 @@ export function compressImage(file: File, maxSizeBytes: number = 1024 * 1024): P img.src = URL.createObjectURL(file); }); -} \ No newline at end of file +} diff --git a/src/lib/utils/model-capabilities.ts b/src/lib/utils/model-capabilities.ts index 94d2e3e..c3f4252 100644 --- a/src/lib/utils/model-capabilities.ts +++ b/src/lib/utils/model-capabilities.ts @@ -6,4 +6,4 @@ export function supportsImages(model: OpenRouterModel): boolean { export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] { return models.filter(supportsImages); -} \ No newline at end of file +} diff --git a/src/routes/account/models/model-card.svelte b/src/routes/account/models/model-card.svelte index 41adea5..a76d7f4 100644 --- a/src/routes/account/models/model-card.svelte +++ b/src/routes/account/models/model-card.svelte @@ -52,7 +52,7 @@
{model.name} - +
enabled, toggleEnabled} {disabled} />
diff --git a/src/routes/api/cancel-generation/+server.ts b/src/routes/api/cancel-generation/+server.ts index 3e87907..eb76bdf 100644 --- a/src/routes/api/cancel-generation/+server.ts +++ b/src/routes/api/cancel-generation/+server.ts @@ -85,4 +85,4 @@ export const POST: RequestHandler = async ({ request }) => { } return response({ ok: true, cancelled }); -}; \ No newline at end of file +}; diff --git a/src/routes/api/cancel-generation/call.ts b/src/routes/api/cancel-generation/call.ts index 628154a..f4bafea 100644 --- a/src/routes/api/cancel-generation/call.ts +++ b/src/routes/api/cancel-generation/call.ts @@ -14,4 +14,4 @@ export async function callCancelGeneration(args: CancelGenerationRequestBody) { ).map((r) => r.json() as Promise); return res; -} \ No newline at end of file +} diff --git a/src/routes/api/generate-message/cache.ts b/src/routes/api/generate-message/cache.ts index 93522ea..c314bf3 100644 --- a/src/routes/api/generate-message/cache.ts +++ b/src/routes/api/generate-message/cache.ts @@ -1,2 +1,2 @@ // Global cache for AbortControllers keyed by conversation ID -export const generationAbortControllers = new Map(); \ No newline at end of file +export const generationAbortControllers = new Map(); diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 8cfb006..070e1aa 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -8,7 +8,6 @@ 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 { callModal } from '$lib/components/ui/modal/global-modal.svelte'; 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'; @@ -22,29 +21,25 @@ import { isString } from '$lib/utils/is.js'; 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 { Avatar } from 'melt/components'; - import { Debounced, ElementSize, IsMounted, ScrollState } from 'runed'; + import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed'; import SendIcon from '~icons/lucide/arrow-up'; import StopIcon from '~icons/lucide/square'; import ChevronDownIcon from '~icons/lucide/chevron-down'; import ImageIcon from '~icons/lucide/image'; - import LoaderCircleIcon from '~icons/lucide/loader-circle'; import PanelLeftIcon from '~icons/lucide/panel-left'; - import PinIcon from '~icons/lucide/pin'; - import PinOffIcon from '~icons/lucide/pin-off'; import Settings2Icon from '~icons/lucide/settings-2'; import UploadIcon from '~icons/lucide/upload'; import XIcon from '~icons/lucide/x'; import { callGenerateMessage } from '../api/generate-message/call.js'; import { callCancelGeneration } from '../api/cancel-generation/call.js'; import ModelPicker from './model-picker.svelte'; + import AppSidebar from '$lib/components/app-sidebar.svelte'; const client = useConvexClient(); - let { data, children } = $props(); + let { children } = $props(); let form = $state(); let textarea = $state(); @@ -118,10 +113,6 @@ } } - const conversationsQuery = useCachedQuery(api.conversations.get, { - session_token: session.current?.session.token ?? '', - }); - const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { provider: Provider.OpenRouter, session_token: session.current?.session.token ?? '', @@ -133,93 +124,7 @@ const autosize = new TextareaAutosize(); - function groupConversationsByTime(conversations: Doc<'conversations'>[]) { - const now = Date.now(); - const oneDay = 24 * 60 * 60 * 1000; - const sevenDays = 7 * oneDay; - const thirtyDays = 30 * oneDay; - - const groups = { - pinned: [] as Doc<'conversations'>[], - today: [] as Doc<'conversations'>[], - yesterday: [] as Doc<'conversations'>[], - lastWeek: [] as Doc<'conversations'>[], - lastMonth: [] as Doc<'conversations'>[], - older: [] as Doc<'conversations'>[], - }; - - conversations.forEach((conversation) => { - // Pinned conversations go to pinned group regardless of time - if (conversation.pinned) { - groups.pinned.push(conversation); - return; - } - - const updatedAt = conversation.updated_at ?? 0; - const timeDiff = now - updatedAt; - - if (timeDiff < oneDay) { - groups.today.push(conversation); - } else if (timeDiff < 2 * oneDay) { - groups.yesterday.push(conversation); - } else if (timeDiff < sevenDays) { - groups.lastWeek.push(conversation); - } else if (timeDiff < thirtyDays) { - groups.lastMonth.push(conversation); - } else { - groups.older.push(conversation); - } - }); - - // Sort pinned conversations by updated_at (most recent first) - groups.pinned.sort((a, b) => { - const aTime = a.updated_at ?? 0; - const bTime = b.updated_at ?? 0; - return bTime - aTime; - }); - - return groups; - } - - const groupedConversations = $derived(groupConversationsByTime(conversationsQuery.data ?? [])); - - async function togglePin(conversationId: string) { - if (!session.current?.session.token) return; - - await client.mutation(api.conversations.togglePin, { - conversation_id: conversationId as Id<'conversations'>, - session_token: session.current.session.token, - }); - } - - async function deleteConversation(conversationId: string) { - const res = await callModal({ - title: 'Delete conversation', - description: 'Are you sure you want to delete this conversation?', - actions: { cancel: 'outline', delete: 'destructive' }, - }); - - if (res !== 'delete') return; - - if (!session.current?.session.token) return; - - await client.mutation(api.conversations.remove, { - conversation_id: conversationId as Id<'conversations'>, - session_token: session.current.session.token, - }); - goto(`/chat`); - } - - const templateConversations = $derived([ - { key: 'pinned', label: 'Pinned', conversations: groupedConversations.pinned, icon: PinIcon }, - { key: 'today', label: 'Today', conversations: groupedConversations.today }, - { key: 'yesterday', label: 'Yesterday', conversations: groupedConversations.yesterday }, - { key: 'lastWeek', label: 'Last 7 days', conversations: groupedConversations.lastWeek }, - { key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth }, - { key: 'older', label: 'Older', conversations: groupedConversations.older }, - ]); - - let message = $state(''); + const message = new PersistedState('prompt', ''); let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]); let isUploading = $state(false); let fileInput = $state(); @@ -230,8 +135,8 @@ }); usePrompt( - () => message, - (v) => (message = v) + () => message.current, + (v) => (message.current = v) ); models.init(); @@ -327,7 +232,7 @@ const cursor = textarea.selectionStart; - const index = message.lastIndexOf('@', cursor); + const index = message.current.lastIndexOf('@', cursor); if (index === -1) return; const ruleFromCursor = message.slice(index + 1, cursor); @@ -357,10 +262,11 @@ const cursor = textarea.selectionStart; - const index = message.lastIndexOf('@', cursor); + const index = message.current.lastIndexOf('@', cursor); if (index === -1) return; - message = message.slice(0, index) + `@${rule.name}` + message.slice(cursor); + 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; @@ -447,144 +353,9 @@ class="h-screen overflow-clip" {...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}} > - -
- Thom.chat -
-
- - {#snippet trigger(tooltip)} - - New Chat - - {/snippet} - {cmdOrCtrl} + Shift + O - -
-
-
-
- {#each templateConversations as group, index (group.key)} - {@const IconComponent = group.icon} - {#if group.conversations.length > 0} -
0}> -

- {#if IconComponent} - - {/if} - {group.label} -

-
- {#each group.conversations as conversation (conversation._id)} - {@const isActive = page.params.id === conversation._id} - -
-

- {conversation.title} -

-
- {#if conversation.generating} -
- -
- {/if} -
-
- - {#snippet trigger(tooltip)} - - {/snippet} - {conversation.pinned ? 'Unpin thread' : 'Pin thread'} - - - {#snippet trigger(tooltip)} - - {/snippet} - Delete thread - -
-
-
- {/each} - {/if} - {/each} -
-
-
-
- {#if data.session !== null} - - {:else} - - {/if} -
-
+ - + {#snippet trigger(tooltip)} @@ -759,7 +530,7 @@ popover.open = true; } }} - bind:value={message} + bind:value={message.current} autofocus autocomplete="off" {@attach autosize.attachment} @@ -772,7 +543,7 @@ @@ -117,7 +117,7 @@ diff --git a/src/routes/chat/model-picker.svelte b/src/routes/chat/model-picker.svelte index 1a2e05a..d68df71 100644 --- a/src/routes/chat/model-picker.svelte +++ b/src/routes/chat/model-picker.svelte @@ -28,6 +28,8 @@ import { models as modelsState } from '$lib/state/models.svelte'; import { Provider } from '$lib/types'; import Tooltip from '$lib/components/ui/tooltip.svelte'; + import fuzzysearch from '$lib/utils/fuzzy-search'; + import { IsMobile } from '$lib/hooks/is-mobile.svelte'; type Props = { class?: string; @@ -109,11 +111,21 @@ return 'other'; } + let search = $state(''); + + const filteredModels = $derived( + fuzzysearch({ + haystack: enabledArr, + needle: search, + property: 'model_id', + }) + ); + // Group models by company const groupedModels = $derived.by(() => { - const groups: Record = {}; + const groups: Record = {}; - enabledArr.forEach((model) => { + filteredModels.forEach((model) => { const company = getCompanyFromModelId(model.model_id); if (!groups[company]) { groups[company] = []; @@ -184,6 +196,8 @@ secondary: formattedParts.slice(1).join(' '), }; } + + const isMobile = new IsMobile(); {#if enabledArr.length === 0} @@ -216,12 +230,19 @@ {...popover.content} class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex" > - -