diff --git a/package.json b/package.json
index 35a9c56..34f6f59 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 29a7344..df2caad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,6 +123,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
@@ -1065,6 +1068,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==}
@@ -1440,6 +1446,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'}
@@ -1714,6 +1723,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'}
@@ -3477,6 +3490,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)':
@@ -3847,6 +3863,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: {}
@@ -4172,6 +4192,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
diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts
index 46353db..12c7746 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,
});
@@ -87,14 +89,18 @@ export const create = mutation({
export const createAndAddMessage = mutation({
args: {
content: v.string(),
+ content_html: v.optional(v.string()),
role: messageRoleValidator,
session_token: v.string(),
- web_search_enabled: v.optional(v.boolean()),
- 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,
@@ -121,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,
diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts
index e01fd31..b7689a6 100644
--- a/src/lib/backend/convex/messages.ts
+++ b/src/lib/backend/convex/messages.ts
@@ -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(),
@@ -42,11 +43,15 @@ export const create = mutation({
token_count: v.optional(v.number()),
web_search_enabled: v.optional(v.boolean()),
// 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, {
@@ -74,6 +79,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,
@@ -98,6 +104,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, {
@@ -116,6 +123,7 @@ export const updateContent = mutation({
await ctx.db.patch(message._id, {
content: args.content,
+ content_html: args.content_html,
});
},
});
@@ -127,6 +135,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, {
@@ -147,6 +156,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,
});
},
});
diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts
index ba5b537..11df54b 100644
--- a/src/lib/backend/convex/schema.ts
+++ b/src/lib/backend/convex/schema.ts
@@ -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),
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
+
+
+
+
+ {#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/markdown-it.ts b/src/lib/utils/markdown-it.ts
new file mode 100644
index 0000000..2bab4ae
--- /dev/null
+++ b/src/lib/utils/markdown-it.ts
@@ -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 }
\ No newline at end of file
diff --git a/src/lib/utils/markdown.svelte.ts b/src/lib/utils/markdown.svelte.ts
index 9de8909..0dc1736 100644
--- a/src/lib/utils/markdown.svelte.ts
+++ b/src/lib/utils/markdown.svelte.ts
@@ -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(null);
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}
- {model.id}
+ {model.id}
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/+server.ts b/src/routes/api/generate-message/+server.ts
index e4347af..f1183be 100644
--- a/src/routes/api/generate-message/+server.ts
+++ b/src/routes/api/generate-message/+server.ts
@@ -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;
@@ -386,6 +387,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,
@@ -405,6 +411,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, {
@@ -413,6 +425,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}`
),
@@ -528,6 +541,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,
web_search_enabled: args.web_search_enabled,
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 fef4409..01116cf 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,19 +21,14 @@
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';
@@ -42,10 +36,11 @@
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();
@@ -120,10 +115,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 ?? '',
@@ -135,93 +126,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();
@@ -232,8 +137,8 @@
});
usePrompt(
- () => message,
- (v) => (message = v)
+ () => message.current,
+ (v) => (message.current = v)
);
models.init();
@@ -329,7 +234,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);
@@ -359,10 +264,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;
@@ -449,144 +355,9 @@
class="h-screen overflow-clip"
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
>
-
-
- Thom.chat
-
-
-
- {#snippet trigger(tooltip)}
-
- New Chat
-
- {/snippet}
- {cmdOrCtrl} + Shift + O
-
-
-
-
- {#if data.session !== null}
-
- {:else}
-
- {/if}
-
-
+
-
+
{#snippet trigger(tooltip)}
@@ -761,7 +532,7 @@
popover.open = true;
}
}}
- bind:value={message}
+ bind:value={message.current}
autofocus
autocomplete="off"
{@attach autosize.attachment}
@@ -774,7 +545,7 @@
-
+
{#each Object.entries(suggestionCategories) as [category, opts] (category)}
@@ -117,7 +117,7 @@
diff --git a/src/routes/chat/[id]/markdown-renderer.svelte b/src/routes/chat/[id]/markdown-renderer.svelte
index 82e45d4..a33677e 100644
--- a/src/routes/chat/[id]/markdown-renderer.svelte
+++ b/src/routes/chat/[id]/markdown-renderer.svelte
@@ -1,4 +1,5 @@
-{@html markdown.current}
+{@html sanitizeHtml(markdown.current ?? '')}
diff --git a/src/routes/chat/[id]/message.svelte b/src/routes/chat/[id]/message.svelte
index d319804..835caf0 100644
--- a/src/routes/chat/[id]/message.svelte
+++ b/src/routes/chat/[id]/message.svelte
@@ -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 @@
{/if}
-
-
+ {#if message.content_html}
+ {@html sanitizeHtml(message.content_html)}
+ {:else}
+
+
- {#snippet failed(error)}
-
- Error rendering markdown:
-
-
- {/snippet}
-
+ {#snippet failed(error)}
+
+ Error rendering markdown:
+
+
+ {/snippet}
+
+ {/if}
{
- 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"
>
-
-