Merge pull request #5 from TGlide/model-selection

WIP Model List
This commit is contained in:
Thomas G. Lopes 2025-06-15 21:52:08 +01:00 committed by GitHub
commit d6b6377fd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 657 additions and 69 deletions

View file

@ -8,6 +8,7 @@
"paths": {
"*": "$lib/blocks",
"utils": "$lib/utils",
"ts": "$lib/utils",
"ui": "$lib/components/ui",
"actions": "$lib/actions",
"hooks": "$lib/hooks"

View file

@ -41,13 +41,13 @@
"jsdom": "^26.0.0",
"melt": "^0.35.0",
"mode-watcher": "^1.0.8",
"neverthrow": "^8.2.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"runed": "^0.28.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-toolbelt": "^0.9.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
@ -63,6 +63,7 @@
]
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
"better-auth": "^1.2.9"
}
}

17
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@floating-ui/dom':
specifier: ^1.7.1
version: 1.7.1
better-auth:
specifier: ^1.2.9
version: 1.2.9
@ -84,6 +87,9 @@ importers:
mode-watcher:
specifier: ^1.0.8
version: 1.0.8(svelte@5.34.1)
neverthrow:
specifier: ^8.2.0
version: 8.2.0
prettier:
specifier: ^3.4.2
version: 3.5.3
@ -102,9 +108,6 @@ importers:
svelte-check:
specifier: ^4.0.0
version: 4.2.1(picomatch@4.0.2)(svelte@5.34.1)(typescript@5.8.3)
svelte-toolbelt:
specifier: ^0.9.1
version: 0.9.1(svelte@5.34.1)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@ -1745,6 +1748,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neverthrow@8.2.0:
resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==}
engines: {node: '>=18'}
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
@ -3852,6 +3859,10 @@ snapshots:
natural-compare@1.4.0: {}
neverthrow@8.2.0:
optionalDependencies:
'@rollup/rollup-linux-x64-gnu': 4.43.0
nwsapi@2.2.20: {}
optionator@0.9.4:

View file

@ -3,7 +3,8 @@ import type { Handle } from '@sveltejs/kit';
import { svelteKitHandler } from 'better-auth/svelte-kit';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.auth = () => auth.api.getSession({ headers: event.request.headers });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event.locals.auth = () => auth.api.getSession({ headers: event.request.headers }) as any;
return svelteKitHandler({ event, resolve, auth });
};

View file

@ -14,5 +14,14 @@ export const auth = betterAuth({
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
// databaseHooks: {
// user: {
// create: {
// after: async ({ user }) => {
// // TODO: automatically enable default models for the user
// },
// },
// },
// },
plugins: [],
});

View file

@ -6,10 +6,21 @@ export const providerValidator = v.union(...Object.values(Provider).map((p) => v
export default defineSchema({
user_keys: defineTable({
user_id: v.string(),
provider: providerValidator,
user_id: v.id('users'),
key: v.string(),
})
.index('by_user', ['user_id'])
.index('by_provider_user', ['provider', 'user_id']),
user_enabled_models: defineTable({
user_id: v.string(),
provider: providerValidator,
/** Different providers may use different ids for the same model */
model_id: v.string(),
pinned: v.union(v.number(), v.null()),
})
.index('by_user', ['user_id'])
.index('by_model_provider', ['model_id', 'provider'])
.index('by_provider_user', ['provider', 'user_id'])
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
});

View file

@ -0,0 +1,65 @@
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';
import { providerValidator } from './schema';
import * as array from '../../utils/array';
import * as object from '../../utils/object';
export const get_enabled = query({
args: {
user_id: v.string(),
},
handler: async (ctx, args) => {
const models = await ctx.db
.query('user_enabled_models')
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.collect();
return array.toMap(models, (m) => [`${m.provider}:${m.model_id}`, m]);
},
});
export const is_enabled = query({
args: {
user_id: v.string(),
provider: providerValidator,
model_id: v.string(),
},
handler: async (ctx, args) => {
const model = await ctx.db
.query('user_enabled_models')
.withIndex('by_model_provider_user', (q) =>
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', args.user_id)
)
.first();
return !!model;
},
});
export const set = mutation({
args: {
provider: providerValidator,
model_id: v.string(),
user_id: v.string(),
enabled: v.boolean(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('user_enabled_models')
.withIndex('by_model_provider', (q) =>
q.eq('model_id', args.model_id).eq('provider', args.provider)
)
.first();
if (args.enabled && existing) return; // nothing to do here
if (existing) {
await ctx.db.delete(existing._id);
} else {
await ctx.db.insert('user_enabled_models', {
...object.pick(args, ['provider', 'model_id', 'user_id']),
pinned: null,
});
}
},
});

View file

@ -3,9 +3,9 @@ import { Provider } from '../../types';
import { mutation, query } from './_generated/server';
import { providerValidator } from './schema';
export const get = query({
export const all = query({
args: {
user_id: v.id('users'),
user_id: v.string(),
},
handler: async (ctx, args) => {
const allKeys = await ctx.db
@ -23,10 +23,27 @@ export const get = query({
},
});
export const get = query({
args: {
user_id: v.string(),
provider: providerValidator,
},
handler: async (ctx, args) => {
const key = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>
q.eq('provider', args.provider).eq('user_id', args.user_id)
)
.first();
return key?.key;
},
});
export const set = mutation({
args: {
provider: providerValidator,
user_id: v.id('users'),
user_id: v.string(),
key: v.string(),
},
handler: async (ctx, args) => {

View file

@ -0,0 +1,49 @@
import { ResultAsync } from 'neverthrow';
export interface OpenRouterModel {
id: string;
name: string;
created: number;
description: string;
architecture: OpenRouterArchitecture;
top_provider: OpenRouterTopProvider;
pricing: OpenRouterPricing;
context_length: number;
hugging_face_id: string;
per_request_limits: Record<string, string>;
supported_parameters: string[];
}
interface OpenRouterArchitecture {
input_modalities: string[];
output_modalities: string[];
tokenizer: string;
}
interface OpenRouterTopProvider {
is_moderated: boolean;
}
interface OpenRouterPricing {
prompt: string;
completion: string;
image: string;
request: string;
input_cache_read: string;
input_cache_write: string;
web_search: string;
internal_reasoning: string;
}
export function getOpenRouterModels() {
return ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/models');
const { data } = await res.json();
return data as OpenRouterModel[];
})(),
() => '[open-router] Failed to fetch models'
);
}

View file

@ -0,0 +1,152 @@
import { autoUpdate, computePosition, flip, type Placement } from '@floating-ui/dom';
import { Toaster, type ToasterProps } from 'melt/builders';
import { createAttachmentKey } from 'svelte/attachments';
import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
type ToastData = {
content: string;
variant: 'info' | 'danger';
};
const classMap: Record<ToastData['variant'], string> = {
info: 'border border-blue-400 bg-gradient-to-b from-blue-500 to-blue-600',
danger: 'border border-red-400 bg-gradient-to-b from-red-500 to-red-600',
};
export class LocalToasts {
id: string;
toaster: Toaster<ToastData>;
constructor(props: ToasterProps & { id: string }) {
this.id = props.id;
this.toaster = new Toaster<ToastData>(props);
}
get addToast() {
return this.toaster.addToast;
}
get trigger() {
return {
'data-local-toast-trigger': this.id,
} as const satisfies HTMLButtonAttributes;
}
get toasts() {
const original = this.toaster?.toasts;
return original.map((toast) => {
const attrs = {
'data-local-toast': '',
'data-variant': toast.data.variant,
[createAttachmentKey()]: (node) => {
let placement: Placement = $state('top');
const triggerEl = document.querySelector(`[data-local-toast-trigger=${this.id}]`);
if (!triggerEl) return;
const compute = () =>
computePosition(triggerEl, node, {
strategy: 'absolute',
placement: 'top',
middleware: [flip({ fallbackPlacements: ['left'] })],
}).then(({ x, y, placement: _placement }) => {
placement = _placement;
Object.assign(node.style, {
left: placement === 'top' ? `${x}px` : `${x - 4}px`,
top: placement === 'top' ? `${y - 6}px` : `${y}px`,
});
// Animate
// Cancel any ongoing animations
node.getAnimations().forEach((anim) => anim.cancel());
// Determine animation direction based on placement
let keyframes: Keyframe[] = [];
switch (placement) {
case 'top':
keyframes = [
{ opacity: 0, transform: 'translateY(8px)', scale: '0.8' },
{ opacity: 1, transform: 'translateY(0)', scale: '1' },
];
break;
case 'left':
keyframes = [
{ opacity: 0, transform: 'translateX(8px)', scale: '0.8' },
{ opacity: 1, transform: 'translateX(0)', scale: '1' },
];
break;
}
node.animate(keyframes, {
duration: 500,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
});
});
const reference = node.cloneNode(true) as HTMLElement;
node.before(reference);
reference.style.visibility = 'hidden';
const destroyers = [
autoUpdate(triggerEl, node, compute),
async () => {
// clone node
const cloned = node.cloneNode(true) as HTMLElement;
reference.before(cloned);
reference.remove();
cloned.getAnimations().forEach((anim) => anim.cancel());
// Animate out
// Cancel any ongoing animations
cloned.getAnimations().forEach((anim) => anim.cancel());
// Determine animation direction based on placement
let keyframes: Keyframe[] = [];
switch (placement) {
case 'top':
keyframes = [
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(-8px)' },
];
break;
case 'left':
keyframes = [
{ opacity: 1, transform: 'translateX(0)' },
{ opacity: 0, transform: 'translateX(-8px)' },
];
break;
}
await cloned.animate(keyframes, {
duration: 400,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
}).finished;
cloned.remove();
},
];
return () => destroyers.forEach((d) => d());
},
style: `
/* Float on top of the UI */
position: absolute;
/* Avoid layout interference */
width: max-content;
top: 0;
left: 0;
`,
} as const satisfies HTMLAttributes<HTMLElement>;
return Object.assign(toast, {
class: `${classMap[toast.data.variant]} rounded-full px-2 py-1 text-xs`,
attrs,
});
});
}
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import { box } from 'svelte-toolbelt';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebarSidebar } from './sidebar.svelte.js';

View file

@ -0,0 +1,3 @@
import Switch from './switch.svelte';
export { Switch };

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import { Toggle, type ToggleProps } from 'melt/builders';
import { type ComponentProps } from 'melt';
let {
class: className,
value = $bindable(false),
...rest
}: ComponentProps<ToggleProps> & { class?: string } = $props();
const toggle = new Toggle({
value: () => value ?? false,
onValueChange: (v) => (value = v),
...rest,
});
</script>
<button
{...toggle.trigger}
class={cn(
'bg-muted-foreground/20 relative h-5 w-10 rounded-full transition-all',
{ 'bg-primary': toggle.value },
className
)}
>
<span
class={cn('bg-background absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-all', {
'bg-primary-foreground': toggle.value,
})}
style="transform: translateX({toggle.value ? '20px' : '0px'})"
></span>
</button>

View file

@ -6,3 +6,11 @@ export const Provider = {
} as const;
export type Provider = (typeof Provider)[keyof typeof Provider];
export type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
};

80
src/lib/utils/array.ts Normal file
View file

@ -0,0 +1,80 @@
/*
Installed from @ieedan/std
*/
/** Maps the provided map into an array using the provided mapping function.
*
* @param map Map to be entered into an array
* @param fn A mapping function to transform each pair into an item
* @returns
*
* ## Usage
* ```ts
* console.log(map); // Map(5) { 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1 }
*
* const arr = fromMap(map, (_, value) => value);
*
* console.log(arr); // [5, 4, 3, 2, 1]
* ```
*/
export function fromMap<K, V, T>(map: Map<K, V>, fn: (key: K, value: V) => T): T[] {
const items: T[] = [];
for (const [key, value] of 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.
* @param fn Summing function
* @returns
*
* ## Usage
*
* ```ts
* const total = sum([1, 2, 3, 4, 5], (num) => num);
*
* console.log(total); // 15
* ```
*/
export function sum<T>(arr: T[], fn: (item: T) => number): number {
let total = 0;
for (const item of arr) {
total = total + fn(item);
}
return total;
}
/** Maps the provided array into a map
*
* @param arr Array of items to be entered into a map
* @param fn A mapping function to transform each item into a key value pair
* @returns
*
* ## Usage
* ```ts
* const map = toMap([5, 4, 3, 2, 1], (item, i) => [i, item]);
*
* console.log(map); // Map(5) { 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1 }
* ```
*/
export function toMap<T, V>(
arr: T[],
fn: (item: T, index: number) => [key: string, value: V]
): Record<string, V> {
const map: Record<string, V> = {};
for (let i = 0; i < arr.length; i++) {
const [key, value] = fn(arr[i], i);
map[key] = value;
}
return map;
}

View file

@ -2,3 +2,16 @@
export function keys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key as K))
) as Omit<T, K>;
}
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key as K))) as Pick<
T,
K
>;
}

View file

@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
export async function load() {
// temporary redirect to /chat
redirect(303, '/chat');
}

View file

@ -66,7 +66,7 @@
</div>
</div>
</div>
<div class="space-y-8 pl-12 md:col-start-2">
<div class="pl-12 md:col-start-2">
<div
class="bg-card text-muted-foreground flex w-fit place-items-center gap-2 rounded-lg p-1 text-sm"
>
@ -80,7 +80,9 @@
</a>
{/each}
</div>
{@render children?.()}
<div class="pt-8">
{@render children?.()}
</div>
</div>
</div>
</div>

View file

@ -1,25 +1,12 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { KeyIcon } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Link } from '$lib/components/ui/link';
import { Provider } from '$lib/types';
import { useConvexClient, useQuery } from 'convex-svelte';
import { Provider, type ProviderMeta } from '$lib/types';
import { useQuery } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import ProviderCard from './provider-card.svelte';
const allProviders = Object.values(Provider);
type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
};
const providersMeta: Record<Provider, ProviderMeta> = {
[Provider.OpenRouter]: {
title: 'OpenRouter',
@ -56,21 +43,23 @@
placeholder: 'sk-ant-...',
},
};
const client = useConvexClient();
const keys = useQuery(api.user_keys.get, { user_id: session.current?.user.id ?? '' });
</script>
<h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Bring your own API keys for select models. Messages sent using your API keys will not count
towards your monthly limits.
</h2>
<svelte:head>
<title>API Keys | Thom.chat</title>
</svelte:head>
<div class="mt-8 flex flex-col gap-8">
<div>
<h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Bring your own API keys for select models. Messages sent using your API keys will not count
towards your monthly limits.
</h2>
</div>
<div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} key={(async () => await keys.data?.[provider])()} />
<ProviderCard {provider} {meta} />
{/each}
</div>

View file

@ -4,52 +4,57 @@
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Link } from '$lib/components/ui/link';
import { useConvexClient } from 'convex-svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import type { Provider } from '$lib/types';
type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
};
import type { Provider, ProviderMeta } from '$lib/types';
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
import { ResultAsync } from 'neverthrow';
type Props = {
provider: Provider;
meta: ProviderMeta;
key: Promise<string>;
};
let { provider, meta, key: keyPromise }: Props = $props();
let { provider, meta }: Props = $props();
const id = $props.id();
const keyQuery = useQuery(api.user_keys.get, {
user_id: session.current?.user.id ?? '',
provider,
});
const client = useConvexClient();
let loading = $state(false);
const toasts = new LocalToasts({ id });
async function submit(e: SubmitEvent) {
loading = true;
e.preventDefault();
try {
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const key = formData.get('key');
if (key === null || !session.current?.user.id) return;
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const key = formData.get('key');
if (key === null || !session.current?.user.id) return;
const res = await client.mutation(api.user_keys.set, {
const res = await ResultAsync.fromPromise(
client.mutation(api.user_keys.set, {
provider,
user_id: session.current?.user.id ?? '',
key: `${key}`,
});
}),
(e) => e
);
// TODO: Setup toast notifications
} catch {
} finally {
loading = false;
}
toasts.addToast({
data: {
content: res.isOk() ? 'Saved' : 'Failed to save',
variant: res.isOk() ? 'info' : 'danger',
},
});
loading = false;
}
</script>
@ -63,17 +68,17 @@
</Card.Header>
<Card.Content tag="form" onsubmit={submit}>
<div class="flex flex-col gap-1">
{#await keyPromise}
{#if keyQuery.isLoading}
<div class="bg-input h-9 animate-pulse rounded-md"></div>
{:then key}
{:else}
<Input
type="password"
placeholder={meta.placeholder ?? ''}
autocomplete="off"
name="key"
value={key}
value={keyQuery.data!}
/>
{/await}
{/if}
<span class="text-muted-foreground text-xs">
Get your API key from
<Link href={meta.link} target="_blank" class="text-blue-500">
@ -82,7 +87,13 @@
</span>
</div>
<div class="flex justify-end">
<Button {loading} type="submit">Save</Button>
<Button {loading} type="submit" {...toasts.trigger}>Save</Button>
</div>
</Card.Content>
</Card.Root>
{#each toasts.toasts as toast (toast)}
<div {...toast.attrs} class={toast.class}>
{toast.data.content}
</div>
{/each}

View file

@ -0,0 +1,7 @@
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
export async function load() {
return {
openRouterModels: (await getOpenRouterModels()).unwrapOr([] as OpenRouterModel[]),
};
}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { Provider } from '$lib/types.js';
import { useQuery } from 'convex-svelte';
import ModelCard from './model-card.svelte';
import { session } from '$lib/state/session.svelte';
import { api } from '$lib/backend/convex/_generated/api';
let { data } = $props();
const enabledModels = useQuery(api.user_enabled_models.get_enabled, {
user_id: session.current?.user.id ?? '',
});
$inspect(
enabledModels.data,
!!enabledModels.data?.[`${Provider.OpenRouter}:${data.openRouterModels[0].id}`]
);
</script>
<svelte:head>
<title>Models | Thom.chat</title>
</svelte:head>
<h1 class="text-2xl font-bold">Available Models</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
<div class="mt-8 flex flex-col gap-4">
{#each data.openRouterModels as model (model.id)}
{@const enabled = enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined}
<ModelCard provider={Provider.OpenRouter} {model} {enabled} />
{/each}
</div>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { Provider } from '$lib/types';
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import { useConvexClient } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import { ResultAsync } from 'neverthrow';
type Model = {
id: string;
name: string;
description: string;
};
type Props = {
provider: Provider;
model: Model;
enabled?: boolean;
};
let { provider, model, enabled = false }: Props = $props();
const client = useConvexClient();
function getShortDescription(text: string) {
// match any punctuation followed by a space or the end of the string
const index = text.match(/[.!?](\s|$)/)?.index;
if (index === undefined) return { shortDescription: null, fullDescription: text };
return { shortDescription: text.slice(0, index + 1), fullDescription: text };
}
const { shortDescription, fullDescription } = $derived(getShortDescription(model.description));
let showMore = $state(false);
async function toggleEnabled(v: boolean) {
enabled = v; // Optimistic!
if (!session.current?.user.id) return;
const res = await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.set, {
provider,
user_id: session.current.user.id,
model_id: model.id,
enabled: v,
}),
(e) => e
);
if (res.isErr()) enabled = !v; // Should have been a realist :(
}
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title>{model.name}</Card.Title>
<!-- TODO: make this actually work -->
<Switch bind:value={() => enabled, toggleEnabled} />
</div>
<Card.Description
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
>
{#if shortDescription !== null}
<button
type="button"
class="text-muted-foreground w-fit text-start text-xs"
onclick={() => (showMore = !showMore)}
>
{showMore ? 'Show less' : 'Show more'}
</button>
{/if}
</Card.Header>
</Card.Root>

View file

@ -7,6 +7,10 @@
let { data, children } = $props();
</script>
<svelte:head>
<title>Chat | Thom.chat</title>
</svelte:head>
<Sidebar.Root>
<Sidebar.Sidebar class="flex flex-col p-2">
<div class="flex place-items-center justify-center py-2">
@ -19,10 +23,15 @@
<div class="py-2">
{#if data.session !== null}
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start">
<Avatar src={data.session.user.image}>
<Avatar src={data.session.user.image ?? undefined}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback}>{data.session?.user.name}</span>
<span {...avatar.fallback}
>{data.session?.user.name
.split(' ')
.map((name) => name[0].toUpperCase())
.join('')}</span
>
{/snippet}
</Avatar>
<div class="flex flex-col">