Merge branch 'main' into web-search
This commit is contained in:
commit
a5fe3d7860
25 changed files with 535 additions and 347 deletions
|
|
@ -41,6 +41,7 @@
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"isomorphic-dompurify": "^2.25.0",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
|
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
|
||||||
"mode-watcher": "^1.0.8",
|
"mode-watcher": "^1.0.8",
|
||||||
|
|
|
||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
|
@ -123,6 +123,9 @@ importers:
|
||||||
globals:
|
globals:
|
||||||
specifier: ^16.0.0
|
specifier: ^16.0.0
|
||||||
version: 16.2.0
|
version: 16.2.0
|
||||||
|
isomorphic-dompurify:
|
||||||
|
specifier: ^2.25.0
|
||||||
|
version: 2.25.0
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^26.0.0
|
specifier: ^26.0.0
|
||||||
version: 26.1.0
|
version: 26.1.0
|
||||||
|
|
@ -1065,6 +1068,9 @@ packages:
|
||||||
'@types/node@24.0.1':
|
'@types/node@24.0.1':
|
||||||
resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==}
|
resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
|
|
@ -1440,6 +1446,9 @@ packages:
|
||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
|
dompurify@3.2.6:
|
||||||
|
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
|
||||||
|
|
||||||
dotenv@16.5.0:
|
dotenv@16.5.0:
|
||||||
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -1714,6 +1723,10 @@ packages:
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isomorphic-dompurify@2.25.0:
|
||||||
|
resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
jest-axe@9.0.0:
|
jest-axe@9.0.0:
|
||||||
resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==}
|
resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==}
|
||||||
engines: {node: '>= 16.0.0'}
|
engines: {node: '>= 16.0.0'}
|
||||||
|
|
@ -3477,6 +3490,9 @@ snapshots:
|
||||||
undici-types: 7.8.0
|
undici-types: 7.8.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@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)':
|
'@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: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
|
dompurify@3.2.6:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dotenv@16.5.0: {}
|
dotenv@16.5.0: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
@ -4172,6 +4192,16 @@ snapshots:
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
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:
|
jest-axe@9.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
axe-core: 4.9.1
|
axe-core: 4.9.1
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ export const get = query({
|
||||||
|
|
||||||
export const getById = query({
|
export const getById = query({
|
||||||
args: {
|
args: {
|
||||||
conversation_id: v.id('conversations'),
|
conversation_id: v.optional(v.id('conversations')),
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
if (!args.conversation_id) return null;
|
||||||
|
|
||||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
session_token: args.session_token,
|
session_token: args.session_token,
|
||||||
});
|
});
|
||||||
|
|
@ -87,14 +89,18 @@ export const create = mutation({
|
||||||
export const createAndAddMessage = mutation({
|
export const createAndAddMessage = mutation({
|
||||||
args: {
|
args: {
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
|
content_html: v.optional(v.string()),
|
||||||
role: messageRoleValidator,
|
role: messageRoleValidator,
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
web_search_enabled: v.optional(v.boolean()),
|
images: v.optional(
|
||||||
images: v.optional(v.array(v.object({
|
v.array(
|
||||||
url: v.string(),
|
v.object({
|
||||||
storage_id: v.string(),
|
url: v.string(),
|
||||||
fileName: v.optional(v.string()),
|
storage_id: v.string(),
|
||||||
}))),
|
fileName: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
handler: async (
|
handler: async (
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -121,6 +127,7 @@ export const createAndAddMessage = mutation({
|
||||||
|
|
||||||
const messageId = await ctx.runMutation(api.messages.create, {
|
const messageId = await ctx.runMutation(api.messages.create, {
|
||||||
content: args.content,
|
content: args.content,
|
||||||
|
content_html: args.content_html,
|
||||||
role: args.role,
|
role: args.role,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
session_token: args.session_token,
|
session_token: args.session_token,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
conversation_id: v.string(),
|
conversation_id: v.string(),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
|
content_html: v.optional(v.string()),
|
||||||
role: messageRoleValidator,
|
role: messageRoleValidator,
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
|
|
||||||
|
|
@ -42,11 +43,15 @@ export const create = mutation({
|
||||||
token_count: v.optional(v.number()),
|
token_count: v.optional(v.number()),
|
||||||
web_search_enabled: v.optional(v.boolean()),
|
web_search_enabled: v.optional(v.boolean()),
|
||||||
// Optional image attachments
|
// Optional image attachments
|
||||||
images: v.optional(v.array(v.object({
|
images: v.optional(
|
||||||
url: v.string(),
|
v.array(
|
||||||
storage_id: v.string(),
|
v.object({
|
||||||
fileName: v.optional(v.string()),
|
url: v.string(),
|
||||||
}))),
|
storage_id: v.string(),
|
||||||
|
fileName: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args): Promise<Id<'messages'>> => {
|
handler: async (ctx, args): Promise<Id<'messages'>> => {
|
||||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
|
@ -74,6 +79,7 @@ export const create = mutation({
|
||||||
ctx.db.insert('messages', {
|
ctx.db.insert('messages', {
|
||||||
conversation_id: args.conversation_id,
|
conversation_id: args.conversation_id,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
|
content_html: args.content_html,
|
||||||
role: args.role,
|
role: args.role,
|
||||||
// Optional, coming from SK API route
|
// Optional, coming from SK API route
|
||||||
model_id: args.model_id,
|
model_id: args.model_id,
|
||||||
|
|
@ -98,6 +104,7 @@ export const updateContent = mutation({
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
message_id: v.string(),
|
message_id: v.string(),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
|
content_html: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
|
@ -116,6 +123,7 @@ export const updateContent = mutation({
|
||||||
|
|
||||||
await ctx.db.patch(message._id, {
|
await ctx.db.patch(message._id, {
|
||||||
content: args.content,
|
content: args.content,
|
||||||
|
content_html: args.content_html,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -127,6 +135,7 @@ export const updateMessage = mutation({
|
||||||
token_count: v.optional(v.number()),
|
token_count: v.optional(v.number()),
|
||||||
cost_usd: v.optional(v.number()),
|
cost_usd: v.optional(v.number()),
|
||||||
generation_id: v.optional(v.string()),
|
generation_id: v.optional(v.string()),
|
||||||
|
content_html: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
|
@ -147,6 +156,7 @@ export const updateMessage = mutation({
|
||||||
token_count: args.token_count,
|
token_count: args.token_count,
|
||||||
cost_usd: args.cost_usd,
|
cost_usd: args.cost_usd,
|
||||||
generation_id: args.generation_id,
|
generation_id: args.generation_id,
|
||||||
|
content_html: args.content_html,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export default defineSchema({
|
||||||
conversation_id: v.string(),
|
conversation_id: v.string(),
|
||||||
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
|
content_html: v.optional(v.string()),
|
||||||
// Optional, coming from SK API route
|
// Optional, coming from SK API route
|
||||||
model_id: v.optional(v.string()),
|
model_id: v.optional(v.string()),
|
||||||
provider: v.optional(providerValidator),
|
provider: v.optional(providerValidator),
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,3 @@ export const deleteFile = mutation({
|
||||||
await ctx.storage.delete(args.storage_id);
|
await ctx.storage.delete(args.storage_id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
253
src/lib/components/app-sidebar.svelte
Normal file
253
src/lib/components/app-sidebar.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Sidebar, useSidebarControls } from '$lib/components/ui/sidebar';
|
||||||
|
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { cn } from '$lib/utils/utils.js';
|
||||||
|
import { Avatar } from 'melt/components';
|
||||||
|
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
||||||
|
import PinOffIcon from '~icons/lucide/pin-off';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { callModal } from './ui/modal/global-modal.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
|
import PinIcon from '~icons/lucide/pin';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const controls = useSidebarControls();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
await goto(`/chat`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationsQuery = useCachedQuery(api.conversations.get, {
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ?? []));
|
||||||
|
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar class="flex flex-col overflow-clip p-2">
|
||||||
|
<div class="flex place-items-center justify-center py-2">
|
||||||
|
<span class="text-center font-serif text-lg">Thom.chat</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex w-full px-2">
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<a
|
||||||
|
href="/chat"
|
||||||
|
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
|
||||||
|
{...tooltip.trigger}
|
||||||
|
onclick={controls.closeMobile}
|
||||||
|
style="font-variation-settings: 'wght' 750"
|
||||||
|
>
|
||||||
|
New Chat
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
{cmdOrCtrl} + Shift + O
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
|
||||||
|
<div
|
||||||
|
class="from-sidebar pointer-events-none absolute top-0 right-0 left-0 z-10 h-4 bg-gradient-to-b to-transparent"
|
||||||
|
></div>
|
||||||
|
<div class="flex flex-1 flex-col overflow-y-auto py-2">
|
||||||
|
{#each templateConversations as group, index (group.key)}
|
||||||
|
{@const IconComponent = group.icon}
|
||||||
|
{#if group.conversations.length > 0}
|
||||||
|
<div class="px-2 py-1" class:mt-2={index > 0}>
|
||||||
|
<h3 class="text-heading text-xs font-medium">
|
||||||
|
{#if IconComponent}
|
||||||
|
<IconComponent class="inline size-3" />
|
||||||
|
{/if}
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{#each group.conversations as conversation (conversation._id)}
|
||||||
|
{@const isActive = page.params.id === conversation._id}
|
||||||
|
<a
|
||||||
|
href={`/chat/${conversation._id}`}
|
||||||
|
onclick={controls.closeMobile}
|
||||||
|
class="group w-full py-0.5 pr-2.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'relative flex w-full items-center justify-between overflow-clip rounded-lg',
|
||||||
|
{ 'bg-sidebar-accent': isActive, 'group-hover:bg-sidebar-accent': !isActive }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p class="truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap">
|
||||||
|
<span>{conversation.title}</span>
|
||||||
|
</p>
|
||||||
|
<div class="pr-2">
|
||||||
|
{#if conversation.generating}
|
||||||
|
<div
|
||||||
|
class="flex animate-[spin_0.75s_linear_infinite] place-items-center justify-center"
|
||||||
|
>
|
||||||
|
<LoaderCircleIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0',
|
||||||
|
'to-sidebar-accent via-sidebar-accent bg-gradient-to-r from-transparent from-10% via-21% ',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<button
|
||||||
|
{...tooltip.trigger}
|
||||||
|
class="hover:bg-muted rounded-md p-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePin(conversation._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if conversation.pinned}
|
||||||
|
<PinOffIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<PinIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
{conversation.pinned ? 'Unpin thread' : 'Pin thread'}
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<button
|
||||||
|
{...tooltip.trigger}
|
||||||
|
class="hover:bg-muted rounded-md p-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteConversation(conversation._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
Delete thread
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="from-sidebar pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-4 bg-gradient-to-t to-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="py-2">
|
||||||
|
{#if page.data.session !== null}
|
||||||
|
<Button href="/account" variant="ghost" class="h-auto w-full justify-start">
|
||||||
|
<Avatar src={page.data.session?.user.image ?? undefined}>
|
||||||
|
{#snippet children(avatar)}
|
||||||
|
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
|
||||||
|
<span {...avatar.fallback} class="size-10 rounded-full">
|
||||||
|
{page.data.session?.user.name
|
||||||
|
.split(' ')
|
||||||
|
.map((name: string) => name[0]?.toUpperCase())
|
||||||
|
.join('')}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm">{page.data.session?.user.name}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">{page.data.session?.user.email}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button href="/login" class="w-full">Login</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Sidebar>
|
||||||
|
|
@ -2,5 +2,6 @@ import Root from './sidebar.svelte';
|
||||||
import Sidebar from './sidebar-sidebar.svelte';
|
import Sidebar from './sidebar-sidebar.svelte';
|
||||||
import Inset from './sidebar-inset.svelte';
|
import Inset from './sidebar-inset.svelte';
|
||||||
import Trigger from './sidebar-trigger.svelte';
|
import Trigger from './sidebar-trigger.svelte';
|
||||||
|
import { useSidebarControls } from './sidebar.svelte.js';
|
||||||
|
|
||||||
export { Root, Sidebar, Inset, Trigger };
|
export { Root, Sidebar, Inset, Trigger, useSidebarControls };
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ export class SidebarRootState {
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeMobile() {
|
||||||
|
if (this.isMobile.current) {
|
||||||
|
this.openMobile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SidebarTriggerState {
|
export class SidebarTriggerState {
|
||||||
|
|
@ -35,6 +41,16 @@ export class SidebarSidebarState {
|
||||||
constructor(readonly root: SidebarRootState) {}
|
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<SidebarRootState>('sidebar-root-context');
|
export const ctx = new Context<SidebarRootState>('sidebar-root-context');
|
||||||
|
|
||||||
export function useSidebar() {
|
export function useSidebar() {
|
||||||
|
|
@ -48,3 +64,7 @@ export function useSidebarTrigger() {
|
||||||
export function useSidebarSidebar() {
|
export function useSidebarSidebar() {
|
||||||
return new SidebarSidebarState(ctx.get());
|
return new SidebarSidebarState(ctx.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSidebarControls() {
|
||||||
|
return new SidebarControlState(ctx.get());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,16 @@ export function fromMap<K, V, T>(map: Map<K, V>, fn: (key: K, value: V) => T): T
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fromRecord<V, T>(map: Record<string, V>, 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.
|
/** Calculates the sum of all elements in the array based on the provided function.
|
||||||
*
|
*
|
||||||
* @param arr Array of items to be summed.
|
* @param arr Array of items to be summed.
|
||||||
|
|
|
||||||
25
src/lib/utils/markdown-it.ts
Normal file
25
src/lib/utils/markdown-it.ts
Normal 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 }
|
||||||
|
|
@ -1,22 +1,5 @@
|
||||||
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
|
|
||||||
import MarkdownItAsync from 'markdown-it-async';
|
|
||||||
import type { Getter } from 'runed';
|
import type { Getter } from 'runed';
|
||||||
import { codeToHtml } from 'shiki';
|
import { md } from './markdown-it';
|
||||||
|
|
||||||
const md = MarkdownItAsync();
|
|
||||||
|
|
||||||
md.use(
|
|
||||||
fromAsyncCodeToHtml(
|
|
||||||
// Pass the codeToHtml function
|
|
||||||
codeToHtml,
|
|
||||||
{
|
|
||||||
themes: {
|
|
||||||
light: 'github-light-default',
|
|
||||||
dark: 'github-dark-default',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export class Markdown {
|
export class Markdown {
|
||||||
highlighted = $state<string | null>(null);
|
highlighted = $state<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
<Card.Title>{model.name}</Card.Title>
|
<Card.Title>{model.name}</Card.Title>
|
||||||
<span class="text-muted-foreground text-xs hidden xl:block">{model.id}</span>
|
<span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { err, ok, Result, ResultAsync } from 'neverthrow';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { generationAbortControllers } from './cache.js';
|
import { generationAbortControllers } from './cache.js';
|
||||||
|
import { md } from '$lib/utils/markdown-it.js';
|
||||||
|
|
||||||
// Set to true to enable debug logging
|
// Set to true to enable debug logging
|
||||||
const ENABLE_LOGGING = true;
|
const ENABLE_LOGGING = true;
|
||||||
|
|
@ -386,6 +387,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentHtmlResultPromise = ResultAsync.fromPromise(
|
||||||
|
md.renderAsync(content),
|
||||||
|
(e) => `Failed to render HTML: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), {
|
const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
|
|
@ -405,6 +411,12 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
|
|
||||||
log('Background: Got generation stats', startTime);
|
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([
|
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
|
||||||
ResultAsync.fromPromise(
|
ResultAsync.fromPromise(
|
||||||
client.mutation(api.messages.updateMessage, {
|
client.mutation(api.messages.updateMessage, {
|
||||||
|
|
@ -413,6 +425,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
cost_usd: generationStats.total_cost,
|
cost_usd: generationStats.total_cost,
|
||||||
generation_id: generationId,
|
generation_id: generationId,
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
|
content_html: contentHtmlResult.unwrapOr(undefined),
|
||||||
}),
|
}),
|
||||||
(e) => `Failed to update message: ${e}`
|
(e) => `Failed to update message: ${e}`
|
||||||
),
|
),
|
||||||
|
|
@ -528,6 +541,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
const convMessageResult = await ResultAsync.fromPromise(
|
const convMessageResult = await ResultAsync.fromPromise(
|
||||||
client.mutation(api.conversations.createAndAddMessage, {
|
client.mutation(api.conversations.createAndAddMessage, {
|
||||||
content: args.message,
|
content: args.message,
|
||||||
|
content_html: '',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
images: args.images,
|
images: args.images,
|
||||||
web_search_enabled: args.web_search_enabled,
|
web_search_enabled: args.web_search_enabled,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ImageModal } from '$lib/components/ui/image-modal';
|
import { ImageModal } from '$lib/components/ui/image-modal';
|
||||||
import { LightSwitch } from '$lib/components/ui/light-switch/index.js';
|
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 * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
|
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
|
||||||
|
|
@ -22,19 +21,14 @@
|
||||||
import { isString } from '$lib/utils/is.js';
|
import { isString } from '$lib/utils/is.js';
|
||||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
import { supportsImages } from '$lib/utils/model-capabilities';
|
||||||
import { omit, pick } from '$lib/utils/object.js';
|
import { omit, pick } from '$lib/utils/object.js';
|
||||||
import { cn } from '$lib/utils/utils.js';
|
|
||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { FileUpload, Popover } from 'melt/builders';
|
import { FileUpload, Popover } from 'melt/builders';
|
||||||
import { Avatar } from 'melt/components';
|
import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed';
|
||||||
import { Debounced, ElementSize, IsMounted, ScrollState } from 'runed';
|
|
||||||
import SendIcon from '~icons/lucide/arrow-up';
|
import SendIcon from '~icons/lucide/arrow-up';
|
||||||
import StopIcon from '~icons/lucide/square';
|
import StopIcon from '~icons/lucide/square';
|
||||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||||
import ImageIcon from '~icons/lucide/image';
|
import ImageIcon from '~icons/lucide/image';
|
||||||
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
|
||||||
import PanelLeftIcon from '~icons/lucide/panel-left';
|
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 Settings2Icon from '~icons/lucide/settings-2';
|
||||||
import UploadIcon from '~icons/lucide/upload';
|
import UploadIcon from '~icons/lucide/upload';
|
||||||
import XIcon from '~icons/lucide/x';
|
import XIcon from '~icons/lucide/x';
|
||||||
|
|
@ -42,10 +36,11 @@
|
||||||
import { callGenerateMessage } from '../api/generate-message/call.js';
|
import { callGenerateMessage } from '../api/generate-message/call.js';
|
||||||
import { callCancelGeneration } from '../api/cancel-generation/call.js';
|
import { callCancelGeneration } from '../api/cancel-generation/call.js';
|
||||||
import ModelPicker from './model-picker.svelte';
|
import ModelPicker from './model-picker.svelte';
|
||||||
|
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let form = $state<HTMLFormElement>();
|
let form = $state<HTMLFormElement>();
|
||||||
let textarea = $state<HTMLTextAreaElement>();
|
let textarea = $state<HTMLTextAreaElement>();
|
||||||
|
|
@ -120,10 +115,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversationsQuery = useCachedQuery(api.conversations.get, {
|
|
||||||
session_token: session.current?.session.token ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
session_token: session.current?.session.token ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
|
|
@ -135,93 +126,7 @@
|
||||||
|
|
||||||
const autosize = new TextareaAutosize();
|
const autosize = new TextareaAutosize();
|
||||||
|
|
||||||
function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
|
const message = new PersistedState('prompt', '');
|
||||||
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('');
|
|
||||||
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
|
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
|
||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
let fileInput = $state<HTMLInputElement>();
|
let fileInput = $state<HTMLInputElement>();
|
||||||
|
|
@ -232,8 +137,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
usePrompt(
|
usePrompt(
|
||||||
() => message,
|
() => message.current,
|
||||||
(v) => (message = v)
|
(v) => (message.current = v)
|
||||||
);
|
);
|
||||||
|
|
||||||
models.init();
|
models.init();
|
||||||
|
|
@ -329,7 +234,7 @@
|
||||||
|
|
||||||
const cursor = textarea.selectionStart;
|
const cursor = textarea.selectionStart;
|
||||||
|
|
||||||
const index = message.lastIndexOf('@', cursor);
|
const index = message.current.lastIndexOf('@', cursor);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
const ruleFromCursor = message.slice(index + 1, cursor);
|
const ruleFromCursor = message.slice(index + 1, cursor);
|
||||||
|
|
@ -359,10 +264,11 @@
|
||||||
|
|
||||||
const cursor = textarea.selectionStart;
|
const cursor = textarea.selectionStart;
|
||||||
|
|
||||||
const index = message.lastIndexOf('@', cursor);
|
const index = message.current.lastIndexOf('@', cursor);
|
||||||
if (index === -1) return;
|
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.selectionStart = index + rule.name.length + 1;
|
||||||
textarea.selectionEnd = index + rule.name.length + 1;
|
textarea.selectionEnd = index + rule.name.length + 1;
|
||||||
|
|
||||||
|
|
@ -449,144 +355,9 @@
|
||||||
class="h-screen overflow-clip"
|
class="h-screen overflow-clip"
|
||||||
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
|
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
|
||||||
>
|
>
|
||||||
<Sidebar.Sidebar class="flex flex-col overflow-clip p-2">
|
<AppSidebar />
|
||||||
<div class="flex place-items-center justify-center py-2">
|
|
||||||
<span class="text-center font-serif text-lg">Thom.chat</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex w-full px-2">
|
|
||||||
<Tooltip>
|
|
||||||
{#snippet trigger(tooltip)}
|
|
||||||
<a
|
|
||||||
href="/chat"
|
|
||||||
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
|
|
||||||
{...tooltip.trigger}
|
|
||||||
style="font-variation-settings: 'wght' 750"
|
|
||||||
>
|
|
||||||
New Chat
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
{cmdOrCtrl} + Shift + O
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
|
|
||||||
<div
|
|
||||||
class="from-sidebar pointer-events-none absolute top-0 right-0 left-0 z-10 h-4 bg-gradient-to-b to-transparent"
|
|
||||||
></div>
|
|
||||||
<div class="flex flex-1 flex-col overflow-y-auto py-2">
|
|
||||||
{#each templateConversations as group, index (group.key)}
|
|
||||||
{@const IconComponent = group.icon}
|
|
||||||
{#if group.conversations.length > 0}
|
|
||||||
<div class="px-2 py-1" class:mt-2={index > 0}>
|
|
||||||
<h3 class="text-heading text-xs font-medium">
|
|
||||||
{#if IconComponent}
|
|
||||||
<IconComponent class="inline size-3" />
|
|
||||||
{/if}
|
|
||||||
{group.label}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{#each group.conversations as conversation (conversation._id)}
|
|
||||||
{@const isActive = page.params.id === conversation._id}
|
|
||||||
<a
|
|
||||||
href={`/chat/${conversation._id}`}
|
|
||||||
class="group w-full py-0.5 pr-2.5 text-left text-sm"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'relative flex w-full items-center justify-between overflow-clip rounded-lg',
|
|
||||||
{ 'bg-sidebar-accent': isActive, 'group-hover:bg-sidebar-accent': !isActive }
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p class="truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap">
|
|
||||||
<span>{conversation.title}</span>
|
|
||||||
</p>
|
|
||||||
<div class="pr-2">
|
|
||||||
{#if conversation.generating}
|
|
||||||
<div
|
|
||||||
class="flex animate-[spin_0.75s_linear_infinite] place-items-center justify-center"
|
|
||||||
>
|
|
||||||
<LoaderCircleIcon class="size-4" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class={[
|
|
||||||
'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0',
|
|
||||||
'to-sidebar-accent via-sidebar-accent bg-gradient-to-r from-transparent from-10% via-21% ',
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
{#snippet trigger(tooltip)}
|
|
||||||
<button
|
|
||||||
{...tooltip.trigger}
|
|
||||||
class="hover:bg-muted rounded-md p-1"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
togglePin(conversation._id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if conversation.pinned}
|
|
||||||
<PinOffIcon class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<PinIcon class="size-4" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
{conversation.pinned ? 'Unpin thread' : 'Pin thread'}
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
{#snippet trigger(tooltip)}
|
|
||||||
<button
|
|
||||||
{...tooltip.trigger}
|
|
||||||
class="hover:bg-muted rounded-md p-1"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteConversation(conversation._id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon class="size-4" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
Delete thread
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="from-sidebar pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-4 bg-gradient-to-t to-transparent"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="py-2">
|
|
||||||
{#if data.session !== null}
|
|
||||||
<Button href="/account" variant="ghost" class="h-auto w-full justify-start">
|
|
||||||
<Avatar src={data.session?.user.image ?? undefined}>
|
|
||||||
{#snippet children(avatar)}
|
|
||||||
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
|
|
||||||
<span {...avatar.fallback} class="size-10 rounded-full">
|
|
||||||
{data.session?.user.name
|
|
||||||
.split(' ')
|
|
||||||
.map((name) => name[0]?.toUpperCase())
|
|
||||||
.join('')}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</Avatar>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm">{data.session?.user.name}</span>
|
|
||||||
<span class="text-muted-foreground text-xs">{data.session?.user.email}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button href="/login" class="w-full">Login</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Sidebar.Sidebar>
|
|
||||||
|
|
||||||
<Sidebar.Inset class="w-full overflow-clip ">
|
<Sidebar.Inset class="w-full overflow-clip px-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{#snippet trigger(tooltip)}
|
{#snippet trigger(tooltip)}
|
||||||
<Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
|
<Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
|
||||||
|
|
@ -761,7 +532,7 @@
|
||||||
popover.open = true;
|
popover.open = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
bind:value={message}
|
bind:value={message.current}
|
||||||
autofocus
|
autofocus
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
{@attach autosize.attachment}
|
{@attach autosize.attachment}
|
||||||
|
|
@ -774,7 +545,7 @@
|
||||||
<button
|
<button
|
||||||
type={isGenerating ? 'button' : 'submit'}
|
type={isGenerating ? 'button' : 'submit'}
|
||||||
onclick={isGenerating ? stopGeneration : undefined}
|
onclick={isGenerating ? stopGeneration : undefined}
|
||||||
disabled={isGenerating ? false : !message.trim()}
|
disabled={isGenerating ? false : !message.current.trim()}
|
||||||
class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50"
|
class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
{...tooltip.trigger}
|
{...tooltip.trigger}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
Be sure to login first.
|
Be sure to login first.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex items-center gap-1">
|
<div class="mt-4 flex flex-wrap items-center gap-1">
|
||||||
{#each Object.entries(suggestionCategories) as [category, opts] (category)}
|
{#each Object.entries(suggestionCategories) as [category, opts] (category)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -105,7 +105,7 @@
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (prompt.current = suggestion)}
|
onclick={() => (prompt.current = suggestion)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full cursor-pointer justify-start py-2 text-start"
|
class="w-full cursor-pointer justify-start px-2 py-2 text-start"
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (prompt.current = suggestion)}
|
onclick={() => (prompt.current = suggestion)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full cursor-pointer justify-start py-2 text-start group-last:line-through"
|
class="w-full cursor-pointer justify-start px-2 py-2 text-start group-last:line-through"
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { sanitizeHtml } from '$lib/utils/markdown-it';
|
||||||
import { Markdown } from '$lib/utils/markdown.svelte';
|
import { Markdown } from '$lib/utils/markdown.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -10,4 +11,4 @@
|
||||||
const markdown = new Markdown(() => content);
|
const markdown = new Markdown(() => content);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@html markdown.current}
|
{@html sanitizeHtml(markdown.current ?? '')}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import '../../../markdown.css';
|
import '../../../markdown.css';
|
||||||
import MarkdownRenderer from './markdown-renderer.svelte';
|
import MarkdownRenderer from './markdown-renderer.svelte';
|
||||||
import { ImageModal } from '$lib/components/ui/image-modal';
|
import { ImageModal } from '$lib/components/ui/image-modal';
|
||||||
|
import { sanitizeHtml } from '$lib/utils/markdown-it';
|
||||||
|
|
||||||
const style = tv({
|
const style = tv({
|
||||||
base: 'prose rounded-xl p-2',
|
base: 'prose rounded-xl p-2',
|
||||||
|
|
@ -58,18 +59,22 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class={style({ role: message.role })}>
|
<div class={style({ role: message.role })}>
|
||||||
<svelte:boundary>
|
{#if message.content_html}
|
||||||
<MarkdownRenderer content={message.content} />
|
{@html sanitizeHtml(message.content_html)}
|
||||||
|
{:else}
|
||||||
|
<svelte:boundary>
|
||||||
|
<MarkdownRenderer content={message.content} />
|
||||||
|
|
||||||
{#snippet failed(error)}
|
{#snippet failed(error)}
|
||||||
<div class="text-destructive">
|
<div class="text-destructive">
|
||||||
<span>Error rendering markdown:</span>
|
<span>Error rendering markdown:</span>
|
||||||
<pre class="!bg-sidebar"><code
|
<pre class="!bg-sidebar"><code
|
||||||
>{error instanceof Error ? error.message : String(error)}</code
|
>{error instanceof Error ? error.message : String(error)}</code
|
||||||
></pre>
|
></pre>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</svelte:boundary>
|
</svelte:boundary>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
import { models as modelsState } from '$lib/state/models.svelte';
|
import { models as modelsState } from '$lib/state/models.svelte';
|
||||||
import { Provider } from '$lib/types';
|
import { Provider } from '$lib/types';
|
||||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
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 = {
|
type Props = {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -109,11 +111,21 @@
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
const filteredModels = $derived(
|
||||||
|
fuzzysearch({
|
||||||
|
haystack: enabledArr,
|
||||||
|
needle: search,
|
||||||
|
property: 'model_id',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Group models by company
|
// Group models by company
|
||||||
const groupedModels = $derived.by(() => {
|
const groupedModels = $derived.by(() => {
|
||||||
const groups: Record<string, typeof enabledArr> = {};
|
const groups: Record<string, typeof filteredModels> = {};
|
||||||
|
|
||||||
enabledArr.forEach((model) => {
|
filteredModels.forEach((model) => {
|
||||||
const company = getCompanyFromModelId(model.model_id);
|
const company = getCompanyFromModelId(model.model_id);
|
||||||
if (!groups[company]) {
|
if (!groups[company]) {
|
||||||
groups[company] = [];
|
groups[company] = [];
|
||||||
|
|
@ -184,6 +196,8 @@
|
||||||
secondary: formattedParts.slice(1).join(' '),
|
secondary: formattedParts.slice(1).join(' '),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = new IsMobile();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if enabledArr.length === 0}
|
{#if enabledArr.length === 0}
|
||||||
|
|
@ -216,12 +230,19 @@
|
||||||
{...popover.content}
|
{...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"
|
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"
|
||||||
>
|
>
|
||||||
<Command.Root class="flex h-full flex-col overflow-hidden" columns={4}>
|
<Command.Root
|
||||||
<label class="group/label relative flex items-center gap-2 px-4 py-3 text-sm">
|
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"
|
||||||
|
>
|
||||||
<SearchIcon class="text-muted-foreground" />
|
<SearchIcon class="text-muted-foreground" />
|
||||||
<Command.Input
|
<Command.Input
|
||||||
class="w-full outline-none"
|
class="w-full outline-none"
|
||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
|
bind:value={search}
|
||||||
{@attach (node) => {
|
{@attach (node) => {
|
||||||
if (popover.open) {
|
if (popover.open) {
|
||||||
node.focus();
|
node.focus();
|
||||||
|
|
@ -231,66 +252,102 @@
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="border-border/50 group-focus-within/label:border-foreground/30 absolute inset-x-2 bottom-0 h-1 border-b"
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
</label>
|
</label>
|
||||||
<Command.List class="overflow-y-auto">
|
<Command.List class="h-[300px] overflow-y-auto md:h-[430px]">
|
||||||
<Command.Viewport>
|
<Command.Viewport>
|
||||||
<Command.Empty class="text-muted-foreground p-4 text-sm">
|
<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.
|
No models available. Enable some models in the account settings.
|
||||||
</Command.Empty>
|
</Command.Empty>
|
||||||
{#each groupedModels as [company, models] (company)}
|
{#each groupedModels as [company, models] (company)}
|
||||||
<Command.Group class="space-y-2">
|
<Command.Group class="space-y-2">
|
||||||
<Command.GroupHeading
|
<Command.GroupHeading
|
||||||
class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
|
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}
|
{company}
|
||||||
</Command.GroupHeading>
|
</Command.GroupHeading>
|
||||||
<Command.GroupItems class="grid grid-cols-4 gap-3 px-3 pb-3">
|
<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)}
|
{#each models as model (model._id)}
|
||||||
{@const isSelected = settings.modelId === model.model_id}
|
{@const isSelected = settings.modelId === model.model_id}
|
||||||
{@const formatted = formatModelName(model.model_id)}
|
{@const formatted = formatModelName(model.model_id)}
|
||||||
<Command.Item
|
{#if isMobile.current}
|
||||||
value={model.model_id}
|
<Command.Item
|
||||||
onSelect={() => selectModel(model.model_id)}
|
value={model.model_id}
|
||||||
class={cn(
|
onSelect={() => selectModel(model.model_id)}
|
||||||
'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2',
|
class={cn(
|
||||||
'relative select-none',
|
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
|
||||||
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
'relative scroll-m-2 select-none',
|
||||||
isSelected && 'border-reflect border-none',
|
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
||||||
'scroll-m-10'
|
isSelected && 'border-reflect border-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#if getModelIcon(model.model_id)}
|
<div class="flex items-center gap-2">
|
||||||
{@const ModelIcon = getModelIcon(model.model_id)}
|
{#if getModelIcon(model.model_id)}
|
||||||
<ModelIcon class="size-6 shrink-0" />
|
{@const ModelIcon = getModelIcon(model.model_id)}
|
||||||
{/if}
|
<ModelIcon class="size-6 shrink-0" />
|
||||||
<p class="font-fake-proxima mt-2 text-center leading-tight font-bold">
|
{/if}
|
||||||
{formatted.primary}
|
<p class="font-fake-proxima text-center leading-tight font-bold">
|
||||||
</p>
|
{formatted.full}
|
||||||
<p class="mt-0 text-center text-xs leading-tight font-medium">
|
</p>
|
||||||
{formatted.secondary}
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
{@const openRouterModel = modelsState
|
{@const openRouterModel = modelsState
|
||||||
.from(Provider.OpenRouter)
|
.from(Provider.OpenRouter)
|
||||||
.find((m) => m.id === model.model_id)}
|
.find((m) => m.id === model.model_id)}
|
||||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{#snippet trigger(tooltip)}
|
{#snippet trigger(tooltip)}
|
||||||
<div
|
<div class="" {...tooltip.trigger}>
|
||||||
class="abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs"
|
<EyeIcon class="size-3" />
|
||||||
{...tooltip.trigger}
|
</div>
|
||||||
>
|
{/snippet}
|
||||||
<EyeIcon class="size-3" />
|
Supports image anaylsis
|
||||||
</div>
|
</Tooltip>
|
||||||
{/snippet}
|
{/if}
|
||||||
Supports image anaylsis
|
</Command.Item>
|
||||||
</Tooltip>
|
{:else}
|
||||||
{/if}
|
<Command.Item
|
||||||
</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 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>
|
||||||
|
|
||||||
|
{@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}
|
{/each}
|
||||||
</Command.GroupItems>
|
</Command.GroupItems>
|
||||||
</Command.Group>
|
</Command.Group>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue