commit
d6b6377fd2
24 changed files with 657 additions and 69 deletions
|
|
@ -8,6 +8,7 @@
|
|||
"paths": {
|
||||
"*": "$lib/blocks",
|
||||
"utils": "$lib/utils",
|
||||
"ts": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"actions": "$lib/actions",
|
||||
"hooks": "$lib/hooks"
|
||||
|
|
|
|||
|
|
@ -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
17
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
});
|
||||
|
|
|
|||
65
src/lib/backend/convex/user_enabled_models.ts
Normal file
65
src/lib/backend/convex/user_enabled_models.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
49
src/lib/backend/models/open-router.ts
Normal file
49
src/lib/backend/models/open-router.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
152
src/lib/builders/local-toasts.svelte.ts
Normal file
152
src/lib/builders/local-toasts.svelte.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
3
src/lib/components/ui/switch/index.ts
Normal file
3
src/lib/components/ui/switch/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Switch from './switch.svelte';
|
||||
|
||||
export { Switch };
|
||||
33
src/lib/components/ui/switch/switch.svelte
Normal file
33
src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
|
|
@ -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
80
src/lib/utils/array.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
6
src/routes/+page.server.ts
Normal file
6
src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export async function load() {
|
||||
// temporary redirect to /chat
|
||||
redirect(303, '/chat');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
7
src/routes/account/models/+page.server.ts
Normal file
7
src/routes/account/models/+page.server.ts
Normal 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[]),
|
||||
};
|
||||
}
|
||||
34
src/routes/account/models/+page.svelte
Normal file
34
src/routes/account/models/+page.svelte
Normal 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>
|
||||
77
src/routes/account/models/model-card.svelte
Normal file
77
src/routes/account/models/model-card.svelte
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue