Merge branch 'main' into model-picker

This commit is contained in:
Aidan Bleser 2025-06-18 08:43:56 -05:00 committed by GitHub
commit 58b0031bd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 219 additions and 115 deletions

View file

@ -41,6 +41,7 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^26.0.0",
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
"mode-watcher": "^1.0.8",

30
pnpm-lock.yaml generated
View file

@ -120,6 +120,9 @@ importers:
globals:
specifier: ^16.0.0
version: 16.2.0
isomorphic-dompurify:
specifier: ^2.25.0
version: 2.25.0
jsdom:
specifier: ^26.0.0
version: 26.1.0
@ -1056,6 +1059,9 @@ packages:
'@types/node@24.0.1':
resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -1424,6 +1430,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.2.6:
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
dotenv@16.5.0:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'}
@ -1698,6 +1707,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-dompurify@2.25.0:
resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==}
engines: {node: '>=18'}
jest-axe@9.0.0:
resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==}
engines: {node: '>= 16.0.0'}
@ -3444,6 +3457,9 @@ snapshots:
undici-types: 7.8.0
optional: true
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)':
@ -3802,6 +3818,10 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dompurify@3.2.6:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@16.5.0: {}
emoji-regex@8.0.0: {}
@ -4127,6 +4147,16 @@ snapshots:
isexe@2.0.0: {}
isomorphic-dompurify@2.25.0:
dependencies:
dompurify: 3.2.6
jsdom: 26.1.0
transitivePeerDependencies:
- bufferutil
- canvas
- supports-color
- utf-8-validate
jest-axe@9.0.0:
dependencies:
axe-core: 4.9.1

View file

@ -89,6 +89,7 @@ export const create = mutation({
export const createAndAddMessage = mutation({
args: {
content: v.string(),
content_html: v.optional(v.string()),
role: messageRoleValidator,
session_token: v.string(),
images: v.optional(
@ -126,6 +127,7 @@ export const createAndAddMessage = mutation({
const messageId = await ctx.runMutation(api.messages.create, {
content: args.content,
content_html: args.content_html,
role: args.role,
conversation_id: conversationId,
session_token: args.session_token,

View file

@ -33,6 +33,7 @@ export const create = mutation({
args: {
conversation_id: v.string(),
content: v.string(),
content_html: v.optional(v.string()),
role: messageRoleValidator,
session_token: v.string(),
@ -77,6 +78,7 @@ export const create = mutation({
ctx.db.insert('messages', {
conversation_id: args.conversation_id,
content: args.content,
content_html: args.content_html,
role: args.role,
// Optional, coming from SK API route
model_id: args.model_id,
@ -100,6 +102,7 @@ export const updateContent = mutation({
session_token: v.string(),
message_id: v.string(),
content: v.string(),
content_html: v.optional(v.string()),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
@ -118,6 +121,7 @@ export const updateContent = mutation({
await ctx.db.patch(message._id, {
content: args.content,
content_html: args.content_html,
});
},
});
@ -129,6 +133,7 @@ export const updateMessage = mutation({
token_count: v.optional(v.number()),
cost_usd: v.optional(v.number()),
generation_id: v.optional(v.string()),
content_html: v.optional(v.string()),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
@ -149,6 +154,7 @@ export const updateMessage = mutation({
token_count: args.token_count,
cost_usd: args.cost_usd,
generation_id: args.generation_id,
content_html: args.content_html,
});
},
});

View file

@ -53,6 +53,7 @@ export default defineSchema({
conversation_id: v.string(),
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(),
content_html: v.optional(v.string()),
// Optional, coming from SK API route
model_id: v.optional(v.string()),
provider: v.optional(providerValidator),

View file

@ -0,0 +1,25 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import MarkdownItAsync from 'markdown-it-async';
import { codeToHtml } from 'shiki';
import DOMPurify from 'isomorphic-dompurify';
const md = MarkdownItAsync();
md.use(
fromAsyncCodeToHtml(
// Pass the codeToHtml function
codeToHtml,
{
themes: {
light: 'github-light-default',
dark: 'github-dark-default',
},
}
)
);
function sanitizeHtml(html: string) {
return DOMPurify.sanitize(html);
}
export { md, sanitizeHtml }

View file

@ -1,22 +1,5 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import MarkdownItAsync from 'markdown-it-async';
import type { Getter } from 'runed';
import { codeToHtml } from 'shiki';
const md = MarkdownItAsync();
md.use(
fromAsyncCodeToHtml(
// Pass the codeToHtml function
codeToHtml,
{
themes: {
light: 'github-light-default',
dark: 'github-dark-default',
},
}
)
);
import { md } from './markdown-it';
export class Markdown {
highlighted = $state<string | null>(null);

View file

@ -10,6 +10,7 @@ import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js';
// Set to true to enable debug logging
const ENABLE_LOGGING = true;
@ -379,6 +380,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content),
(e) => `Failed to render HTML: ${e}`
);
const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), {
delay: 500,
retries: 2,
@ -398,6 +404,12 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
log('Background: Got generation stats', startTime);
const contentHtmlResult = await contentHtmlResultPromise;
if (contentHtmlResult.isErr()) {
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, {
@ -406,6 +418,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
cost_usd: generationStats.total_cost,
generation_id: generationId,
session_token: sessionToken,
content_html: contentHtmlResult.unwrapOr(undefined),
}),
(e) => `Failed to update message: ${e}`
),
@ -521,6 +534,7 @@ export const POST: RequestHandler = async ({ request }) => {
const convMessageResult = await ResultAsync.fromPromise(
client.mutation(api.conversations.createAndAddMessage, {
content: args.message,
content_html: '',
role: 'user',
images: args.images,
session_token: sessionToken,

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { sanitizeHtml } from '$lib/utils/markdown-it';
import { Markdown } from '$lib/utils/markdown.svelte';
type Props = {
@ -10,4 +11,4 @@
const markdown = new Markdown(() => content);
</script>
{@html markdown.current}
{@html sanitizeHtml(markdown.current ?? '')}

View file

@ -6,6 +6,7 @@
import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte';
import { ImageModal } from '$lib/components/ui/image-modal';
import { sanitizeHtml } from '$lib/utils/markdown-it';
const style = tv({
base: 'prose rounded-xl p-2',
@ -58,18 +59,22 @@
</div>
{/if}
<div class={style({ role: message.role })}>
<svelte:boundary>
<MarkdownRenderer content={message.content} />
{#if message.content_html}
{@html sanitizeHtml(message.content_html)}
{:else}
<svelte:boundary>
<MarkdownRenderer content={message.content} />
{#snippet failed(error)}
<div class="text-destructive">
<span>Error rendering markdown:</span>
<pre class="!bg-sidebar"><code
>{error instanceof Error ? error.message : String(error)}</code
></pre>
</div>
{/snippet}
</svelte:boundary>
{#snippet failed(error)}
<div class="text-destructive">
<span>Error rendering markdown:</span>
<pre class="!bg-sidebar"><code
>{error instanceof Error ? error.message : String(error)}</code
></pre>
</div>
{/snippet}
</svelte:boundary>
{/if}
</div>
<div
class={cn(

View file

@ -32,6 +32,23 @@
import MicrosoftIcon from '~icons/simple-icons/microsoft';
import OpenaiIcon from '~icons/simple-icons/openai';
import XIcon from '~icons/simple-icons/x';
import BrainIcon from '~icons/lucide/brain';
import CpuIcon from '~icons/lucide/cpu';
import ZapIcon from '~icons/lucide/zap';
import Cohere from '$lib/components/icons/cohere.svelte';
import Deepseek from '$lib/components/icons/deepseek.svelte';
import { Popover } from 'melt/builders';
import type { Component } from 'svelte';
import LogosClaudeIcon from '~icons/logos/claude-icon';
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
import { capitalize } from '$lib/utils/strings';
import { supportsImages } from '$lib/utils/model-capabilities';
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;
@ -122,10 +139,12 @@
return 'other';
}
let search = $state('');
const filteredModels = $derived(
fuzzysearch({
haystack: enabledArr,
needle: gridCommand.inputValue,
needle: search,
property: 'model_id',
})
);
@ -238,7 +257,11 @@
{...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"
>
<div class="flex h-full flex-col overflow-hidden md:w-[572px]" {...gridCommand.root}>
<Command.Root
shouldFilter={false}
class="flex h-full flex-col overflow-hidden md:w-[572px]"
columns={isMobile.current ? undefined : 4}
>
<label
class="group/label border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm"
>
@ -246,6 +269,7 @@
<input
class="w-full outline-none"
placeholder="Search models..."
bind:value={search}
{@attach (node) => {
if (popover.open) {
node.focus();
@ -264,95 +288,107 @@
})}
/>
</label>
<div class="h-[300px] overflow-y-auto md:h-[430px]">
{#each groupedModels as [company, models] (company)}
<div {...gridCommand.group} class="space-y-2">
<p
class="text-heading/75 flex scroll-m-2 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
{...gridCommand.groupHeading}
>
{company}
</p>
<div class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3">
{#each models as model (model._id)}
{@const isSelected = settings.modelId === model.model_id}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
<Command.List class="h-[300px] overflow-y-auto md:h-[430px]">
<Command.Viewport>
<Command.Empty
class="text-muted-foreground flex items-center justify-center p-4 text-sm md:h-[120px]"
>
No models available. Enable some models in the account settings.
</Command.Empty>
{#each groupedModels as [company, models] (company)}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize md:scroll-m-[180px]"
>
{company}
</Command.GroupHeading>
<Command.GroupItems
class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3"
>
{#each models as model (model._id)}
{@const isSelected = settings.modelId === model.model_id}
{@const formatted = formatModelName(model.model_id)}
{#if isMobile.current}
<Command.Item
value={model.model_id}
onSelect={() => selectModel(model.model_id)}
class={cn(
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
'relative scroll-m-2 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
isSelected && 'border-reflect border-none'
)}
>
<div class="flex items-center gap-2">
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p class="font-fake-proxima text-center leading-tight font-bold">
{formatted.full}
</p>
</div>
{#if isMobile.current}
<div
{...gridCommand.getItem(model.model_id)}
class={cn(
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
'relative scroll-m-2 select-none',
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
isSelected && 'border-reflect border-none'
)}
>
<div class="flex items-center gap-2">
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div class="" {...tooltip.trigger}>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</Command.Item>
{:else}
<Command.Item
value={model.model_id}
onSelect={() => selectModel(model.model_id)}
class={cn(
'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2',
'relative select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
isSelected && 'border-reflect border-none'
)}
>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p class="font-fake-proxima text-center leading-tight font-bold">
{formatted.full}
<p class="font-fake-proxima mt-2 text-center leading-tight font-bold">
{formatted.primary}
</p>
<p class="mt-0 text-center text-xs leading-tight font-medium">
{formatted.secondary}
</p>
</div>
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div class="" {...tooltip.trigger}>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</div>
{:else}
<div
{...gridCommand.getItem(model.model_id)}
class={cn(
'border-border flex h-40 w-32 scroll-m-2 flex-col items-center justify-center rounded-lg border p-2',
'relative select-none',
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
isSelected && 'border-reflect border-none'
)}
>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p class="font-fake-proxima mt-2 text-center leading-tight font-bold">
{formatted.primary}
</p>
<p class="mt-0 text-center text-xs leading-tight font-medium">
{formatted.secondary}
</p>
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
class="abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs"
{...tooltip.trigger}
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
class="abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs"
{...tooltip.trigger}
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</Command.Item>
{/if}
{/each}
</Command.GroupItems>
</Command.Group>
{/each}
</Command.Viewport>
</Command.List>
</Command.Root>
</div>
{/if}