parent
4304e435c9
commit
f63c0b0ba0
30 changed files with 366 additions and 143 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "es5",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,9 @@ IDK, calm down
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [-] Login & Auth
|
- [x] Login & Auth
|
||||||
- [ ] Convex schemas for chats
|
- [ ] Convex schemas for chats
|
||||||
|
- [ ] Actual fucking UI for chat
|
||||||
- [ ] Providers (BYOK)
|
- [ ] Providers (BYOK)
|
||||||
- [ ] Openrouter
|
- [ ] Openrouter
|
||||||
- [ ] HuggingFace
|
- [ ] HuggingFace
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ export default ts.config(
|
||||||
...svelte.configs.prettier,
|
...svelte.configs.prettier,
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: { ...globals.browser, ...globals.node }
|
globals: { ...globals.browser, ...globals.node },
|
||||||
},
|
},
|
||||||
rules: { 'no-undef': 'off' }
|
rules: { 'no-undef': 'off' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
|
@ -29,8 +29,8 @@ export default ts.config(
|
||||||
projectService: true,
|
projectService: true,
|
||||||
extraFileExtensions: ['.svelte'],
|
extraFileExtensions: ['.svelte'],
|
||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run build && npm run preview',
|
command: 'npm run build && npm run preview',
|
||||||
port: 4173
|
port: 4173,
|
||||||
},
|
},
|
||||||
testDir: 'e2e'
|
testDir: 'e2e',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
3
src/app.d.ts
vendored
3
src/app.d.ts
vendored
|
|
@ -1,4 +1,7 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
|
import type { Session, User } from 'better-auth';
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function active(node: HTMLAnchorElement, opts: Omit<Options, 'url'> = {})
|
||||||
*/
|
*/
|
||||||
export function attachActive(opts: Omit<Options, 'url'> = {}) {
|
export function attachActive(opts: Omit<Options, 'url'> = {}) {
|
||||||
return {
|
return {
|
||||||
[createAttachmentKey()]: (node: HTMLAnchorElement) => active(node, opts)
|
[createAttachmentKey()]: (node: HTMLAnchorElement) => active(node, opts),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export const auth = betterAuth({
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
}
|
|
||||||
},
|
},
|
||||||
plugins: []
|
},
|
||||||
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ A query function that takes two arguments looks like:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// functions.js
|
// functions.js
|
||||||
import { query } from "./_generated/server";
|
import { query } from './_generated/server';
|
||||||
import { v } from "convex/values";
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
export const myQueryFunction = query({
|
export const myQueryFunction = query({
|
||||||
// Validators for arguments.
|
// Validators for arguments.
|
||||||
|
|
@ -21,7 +21,7 @@ export const myQueryFunction = query({
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Read the database as many times as you need here.
|
// Read the database as many times as you need here.
|
||||||
// See https://docs.convex.dev/database/reading-data.
|
// See https://docs.convex.dev/database/reading-data.
|
||||||
const documents = await ctx.db.query("tablename").collect();
|
const documents = await ctx.db.query('tablename').collect();
|
||||||
|
|
||||||
// Arguments passed from the client are properties of the args object.
|
// Arguments passed from the client are properties of the args object.
|
||||||
console.log(args.first, args.second);
|
console.log(args.first, args.second);
|
||||||
|
|
@ -38,7 +38,7 @@ Using this query function in a React component looks like:
|
||||||
```ts
|
```ts
|
||||||
const data = useQuery(api.functions.myQueryFunction, {
|
const data = useQuery(api.functions.myQueryFunction, {
|
||||||
first: 10,
|
first: 10,
|
||||||
second: "hello",
|
second: 'hello',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -46,8 +46,8 @@ A mutation function looks like:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// functions.js
|
// functions.js
|
||||||
import { mutation } from "./_generated/server";
|
import { mutation } from './_generated/server';
|
||||||
import { v } from "convex/values";
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
export const myMutationFunction = mutation({
|
export const myMutationFunction = mutation({
|
||||||
// Validators for arguments.
|
// Validators for arguments.
|
||||||
|
|
@ -62,7 +62,7 @@ export const myMutationFunction = mutation({
|
||||||
// Mutations can also read from the database like queries.
|
// Mutations can also read from the database like queries.
|
||||||
// See https://docs.convex.dev/database/writing-data.
|
// See https://docs.convex.dev/database/writing-data.
|
||||||
const message = { body: args.first, author: args.second };
|
const message = { body: args.first, author: args.second };
|
||||||
const id = await ctx.db.insert("messages", message);
|
const id = await ctx.db.insert('messages', message);
|
||||||
|
|
||||||
// Optionally, return a value from your mutation.
|
// Optionally, return a value from your mutation.
|
||||||
return await ctx.db.get(id);
|
return await ctx.db.get(id);
|
||||||
|
|
@ -76,12 +76,10 @@ Using this mutation function in a React component looks like:
|
||||||
const mutation = useMutation(api.functions.myMutationFunction);
|
const mutation = useMutation(api.functions.myMutationFunction);
|
||||||
function handleButtonPress() {
|
function handleButtonPress() {
|
||||||
// fire and forget, the most common way to use mutations
|
// fire and forget, the most common way to use mutations
|
||||||
mutation({ first: "Hello!", second: "me" });
|
mutation({ first: 'Hello!', second: 'me' });
|
||||||
// OR
|
// OR
|
||||||
// use the result once the mutation has completed
|
// use the result once the mutation has completed
|
||||||
mutation({ first: "Hello!", second: "me" }).then((result) =>
|
mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result));
|
||||||
console.log(result),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const { betterAuth, query, insert, update, delete_, count, getSession } = Convex
|
||||||
action,
|
action,
|
||||||
internalQuery,
|
internalQuery,
|
||||||
internalMutation,
|
internalMutation,
|
||||||
internal
|
internal,
|
||||||
}) as ConvexReturnType;
|
}) as ConvexReturnType;
|
||||||
|
|
||||||
export { betterAuth, query, insert, update, delete_, count, getSession };
|
export { betterAuth, query, insert, update, delete_, count, getSession };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { defineSchema, defineTable } from 'convex/server';
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
|
import { Provider } from '../../../lib/types';
|
||||||
|
|
||||||
|
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p)));
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
user_keys: defineTable({
|
user_keys: defineTable({
|
||||||
openRouter: v.string()
|
provider: providerValidator,
|
||||||
|
user_id: v.id('users'),
|
||||||
|
key: v.string(),
|
||||||
})
|
})
|
||||||
|
.index('by_user', ['user_id'])
|
||||||
|
.index('by_provider_user', ['provider', 'user_id']),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
import { mutation } from './_generated/server';
|
|
||||||
import { v } from 'convex/values';
|
|
||||||
46
src/lib/backend/convex/user_keys.ts
Normal file
46
src/lib/backend/convex/user_keys.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { Provider } from '../../types';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { providerValidator } from './schema';
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: {
|
||||||
|
user_id: v.id('users'),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const allKeys = await ctx.db
|
||||||
|
.query('user_keys')
|
||||||
|
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Object.values(Provider).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = allKeys.find((item) => item.provider === key)?.key;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<Provider, string | undefined>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const set = mutation({
|
||||||
|
args: {
|
||||||
|
provider: providerValidator,
|
||||||
|
user_id: v.id('users'),
|
||||||
|
key: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('user_keys')
|
||||||
|
.withIndex('by_provider_user', (q) =>
|
||||||
|
q.eq('provider', args.provider).eq('user_id', args.user_id)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.replace(existing._id, args);
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert('user_keys', args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -9,7 +9,7 @@ import Root, {
|
||||||
type AnchorElementProps,
|
type AnchorElementProps,
|
||||||
type ButtonElementProps,
|
type ButtonElementProps,
|
||||||
type ButtonPropsWithoutHTML,
|
type ButtonPropsWithoutHTML,
|
||||||
buttonVariants
|
buttonVariants,
|
||||||
} from './button.svelte';
|
} from './button.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -23,5 +23,5 @@ export {
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
type AnchorElementProps,
|
type AnchorElementProps,
|
||||||
type ButtonElementProps,
|
type ButtonElementProps,
|
||||||
type ButtonPropsWithoutHTML
|
type ButtonPropsWithoutHTML,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts" generics="Tag extends keyof HTMLElementTagNameMap">
|
||||||
import { cn } from '$lib/utils/utils';
|
import { cn } from '$lib/utils/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
type ElementTag = HTMLElementTagNameMap[Tag];
|
||||||
|
let {
|
||||||
|
tag = 'div' as Tag,
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: HTMLAttributes<ElementTag> & { tag?: Tag } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('flex flex-col gap-2', className)} {...restProps}>
|
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||||
|
<svelte:element this={tag} class={cn('flex flex-col gap-2', className)} {...restProps as any}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</svelte:element>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
|
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
|
||||||
'[--sidebar-width:250px]': sidebar.showSidebar
|
'[--sidebar-width:250px]': sidebar.showSidebar,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
||||||
8
src/lib/state/session.svelte.ts
Normal file
8
src/lib/state/session.svelte.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { Session, User } from 'better-auth';
|
||||||
|
|
||||||
|
export const session = {
|
||||||
|
get current() {
|
||||||
|
return page.data.session as { session: Session; user: User } | null;
|
||||||
|
},
|
||||||
|
};
|
||||||
8
src/lib/types.ts
Normal file
8
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const Provider = {
|
||||||
|
OpenRouter: 'openrouter',
|
||||||
|
HuggingFace: 'huggingface',
|
||||||
|
OpenAI: 'openai',
|
||||||
|
Anthropic: 'anthropic',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Provider = (typeof Provider)[keyof typeof Provider];
|
||||||
4
src/lib/utils/object.ts
Normal file
4
src/lib/utils/object.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// typed object.keys
|
||||||
|
export function keys<T extends object>(obj: T): Array<keyof T> {
|
||||||
|
return Object.keys(obj) as Array<keyof T>;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export async function load({locals}) {
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session
|
session,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { setupConvex } from 'convex-svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
$inspect(data.session);
|
setupConvex(PUBLIC_CONVEX_URL);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,6 @@ export async function load({ locals, url }) {
|
||||||
if (!session) redirectToLogin(url);
|
if (!session) redirectToLogin(url);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session
|
session,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,20 @@
|
||||||
const navigation: { title: string; href: string }[] = [
|
const navigation: { title: string; href: string }[] = [
|
||||||
{
|
{
|
||||||
title: 'Account',
|
title: 'Account',
|
||||||
href: '/account'
|
href: '/account',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Customization',
|
title: 'Customization',
|
||||||
href: '/account/customization'
|
href: '/account/customization',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Models',
|
title: 'Models',
|
||||||
href: '/account/models'
|
href: '/account/models',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'API Keys',
|
title: 'API Keys',
|
||||||
href: '/account/api-keys'
|
href: '/account/api-keys',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
|
|
@ -49,14 +49,19 @@
|
||||||
<div class="px-4 md:grid md:grid-cols-[280px_1fr]">
|
<div class="px-4 md:grid md:grid-cols-[280px_1fr]">
|
||||||
<div class="hidden md:col-start-1 md:block">
|
<div class="hidden md:col-start-1 md:block">
|
||||||
<div class="flex flex-col place-items-center gap-2">
|
<div class="flex flex-col place-items-center gap-2">
|
||||||
<Avatar src={data.session.user.image}>
|
<Avatar src={data.session.user.image ?? undefined}>
|
||||||
{#snippet children(avatar)}
|
{#snippet children(avatar)}
|
||||||
<img {...avatar.image} alt="Your avatar" class="size-40 rounded-full" />
|
<img {...avatar.image} alt="Your avatar" class="size-40 rounded-full" />
|
||||||
<span {...avatar.fallback}>{data.session.user.name}</span>
|
<span {...avatar.fallback}>
|
||||||
|
{data.session.user.name
|
||||||
|
.split(' ')
|
||||||
|
.map((i) => i[0].toUpperCase())
|
||||||
|
.join('')}
|
||||||
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h1 class="text-center text-2xl font-bold">{data.session.user.name}</h1>
|
<p class="text-center text-2xl font-bold">{data.session.user.name}</p>
|
||||||
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
|
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,73 @@
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Link } from '$lib/components/ui/link';
|
import { Link } from '$lib/components/ui/link';
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
|
import { useConvexClient, 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',
|
||||||
|
link: 'https://openrouter.ai/settings/keys',
|
||||||
|
description: 'API Key for OpenRouter.',
|
||||||
|
models: ['a shit ton'],
|
||||||
|
placeholder: 'sk-or-...',
|
||||||
|
},
|
||||||
|
[Provider.HuggingFace]: {
|
||||||
|
title: 'HuggingFace',
|
||||||
|
link: 'https://huggingface.co/settings/tokens',
|
||||||
|
description: 'API Key for HuggingFace, for open-source models.',
|
||||||
|
placeholder: 'hf_...',
|
||||||
|
},
|
||||||
|
[Provider.OpenAI]: {
|
||||||
|
title: 'OpenAI',
|
||||||
|
link: 'https://platform.openai.com/account/api-keys',
|
||||||
|
description: 'API Key for OpenAI.',
|
||||||
|
models: ['gpt-3.5-turbo', 'gpt-4'],
|
||||||
|
placeholder: 'sk-...',
|
||||||
|
},
|
||||||
|
[Provider.Anthropic]: {
|
||||||
|
title: 'Anthropic',
|
||||||
|
link: 'https://console.anthropic.com/account/api-keys',
|
||||||
|
description: 'API Key for Anthropic.',
|
||||||
|
models: [
|
||||||
|
'Claude 3.5 Sonnet',
|
||||||
|
'Claude 3.7 Sonnet',
|
||||||
|
'Claude 3.7 Sonnet (Reasoning)',
|
||||||
|
'Claude 4 Opus',
|
||||||
|
'Claude 4 Sonnet',
|
||||||
|
'Claude 4 Sonnet (Reasoning)',
|
||||||
|
],
|
||||||
|
placeholder: 'sk-ant-...',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const keys = useQuery(api.user_keys.get, { user_id: session.current?.user.id ?? '' });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
<h1 class="text-2xl font-bold">API Keys</h1>
|
||||||
<Card.Header>
|
<h2 class="text-muted-foreground mt-2 text-sm">
|
||||||
<Card.Title>
|
Bring your own API keys for select models. Messages sent using your API keys will not count
|
||||||
<KeyIcon class="inline size-4" /> Open Router
|
towards your monthly limits.
|
||||||
</Card.Title>
|
</h2>
|
||||||
<Card.Description>API Key for OpenRouter.</Card.Description>
|
|
||||||
</Card.Header>
|
<div class="mt-8 flex flex-col gap-8">
|
||||||
<Card.Content>
|
{#each allProviders as provider (provider)}
|
||||||
<div class="flex flex-col gap-1">
|
{@const meta = providersMeta[provider]}
|
||||||
<Input type="password" placeholder="sk-or-..." />
|
<ProviderCard {provider} {meta} key={(async () => await keys.data?.[provider])()} />
|
||||||
<span class="text-muted-foreground text-xs">
|
{/each}
|
||||||
Get your API key from
|
|
||||||
<Link href="https://openrouter.ai/settings/keys" target="_blank" class="text-blue-500">
|
|
||||||
OpenRouter
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button type="submit">Save</Button>
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
|
||||||
88
src/routes/account/api-keys/provider-card.svelte
Normal file
88
src/routes/account/api-keys/provider-card.svelte
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<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 { useConvexClient } 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
provider: Provider;
|
||||||
|
meta: ProviderMeta;
|
||||||
|
key: Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { provider, meta, key: keyPromise }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
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 res = await client.mutation(api.user_keys.set, {
|
||||||
|
provider,
|
||||||
|
user_id: session.current?.user.id ?? '',
|
||||||
|
key: `${key}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Setup toast notifications
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>
|
||||||
|
<KeyIcon class="inline size-4" />
|
||||||
|
{meta.title}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>{meta.description}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content tag="form" onsubmit={submit}>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#await keyPromise}
|
||||||
|
<div class="bg-input h-9 animate-pulse rounded-md"></div>
|
||||||
|
{:then key}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={meta.placeholder ?? ''}
|
||||||
|
autocomplete="off"
|
||||||
|
name="key"
|
||||||
|
value={key}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
Get your API key from
|
||||||
|
<Link href={meta.link} target="_blank" class="text-blue-500">
|
||||||
|
{meta.title}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button {loading} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
|
|
||||||
<div class="flex size-full place-items-center justify-center">
|
<div class="flex size-full place-items-center justify-center">
|
||||||
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
|
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
|
||||||
<form class="relative w-full h-18">
|
<form class="relative h-18 w-full">
|
||||||
<textarea
|
<textarea
|
||||||
class="border-input bg-background h-full ring-ring ring-offset-background w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
||||||
placeholder="Ask me anything..."
|
placeholder="Ask me anything..."
|
||||||
></textarea>
|
></textarea>
|
||||||
<Button type="submit" size="icon" class="absolute bottom-1 right-1 size-8">
|
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: { adapter: adapter() }
|
kit: { adapter: adapter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export default defineConfig({
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
exclude: ['src/lib/server/**'],
|
exclude: ['src/lib/server/**'],
|
||||||
setupFiles: ['./vitest-setup-client.ts']
|
setupFiles: ['./vitest-setup-client.ts'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extends: './vite.config.ts',
|
extends: './vite.config.ts',
|
||||||
|
|
@ -25,9 +25,9 @@ export default defineConfig({
|
||||||
name: 'server',
|
name: 'server',
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ Object.defineProperty(window, 'matchMedia', {
|
||||||
onchange: null,
|
onchange: null,
|
||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
dispatchEvent: vi.fn()
|
dispatchEvent: vi.fn(),
|
||||||
}))
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// add more mocks here if you need them
|
// add more mocks here if you need them
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue