This commit is contained in:
Aidan Bleser 2025-06-16 13:43:53 -05:00 committed by GitHub
parent ff54b6b641
commit 82d3d4f933
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 232 additions and 11 deletions

View file

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

View file

@ -11,6 +11,8 @@ export const messageRoleValidator = v.union(
export type MessageRole = Infer<typeof messageRoleValidator>;
export const ruleAttachValidator = v.union(v.literal('always'), v.literal('manual'));
export default defineSchema({
user_keys: defineTable({
user_id: v.string(),
@ -30,6 +32,15 @@ export default defineSchema({
.index('by_model_provider', ['model_id', 'provider'])
.index('by_provider_user', ['provider', 'user_id'])
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
user_rules: defineTable({
user_id: v.string(),
name: v.string(),
attach: ruleAttachValidator,
rule: v.string(),
})
.index('by_user', ['user_id'])
.index('by_user_attach', ['user_id', 'attach'])
.index('by_user_name', ['user_id', 'name']),
conversations: defineTable({
user_id: v.string(),
title: v.string(),

View file

@ -0,0 +1,55 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { internal } from './_generated/api';
import { ruleAttachValidator } from './schema';
import { Doc } from './_generated/dataModel';
export const create = mutation({
args: {
name: v.string(),
attach: ruleAttachValidator,
rule: v.string(),
sessionToken: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(internal.betterAuth.getSession, {
sessionToken: args.sessionToken,
});
if (!session) throw new Error('Invalid session token');
const existing = await ctx.db
.query('user_rules')
.withIndex('by_user_name', (q) => q.eq('user_id', session.userId).eq('name', args.name))
.first();
if (existing) throw new Error('Rule with this name already exists');
await ctx.db.insert('user_rules', {
user_id: session.userId,
name: args.name,
attach: args.attach,
rule: args.rule,
});
},
});
export const all = query({
args: {
sessionToken: v.string(),
},
handler: async (ctx, args): Promise<Doc<'user_rules'>[]> => {
const session = await ctx.runQuery(internal.betterAuth.getSession, {
sessionToken: args.sessionToken,
});
if (!session) throw new Error('Invalid session token');
const allRules = await ctx.db
.query('user_rules')
.withIndex('by_user', (q) => q.eq('user_id', session.userId))
.collect();
return allRules;
},
});

View file

@ -3,14 +3,14 @@ import { SessionStorageCache } from './session-cache.js';
import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
import { extract, watch } from 'runed';
interface CachedQueryOptions {
export interface CachedQueryOptions {
cacheKey?: string;
ttl?: number;
staleWhileRevalidate?: boolean;
enabled?: boolean;
}
interface QueryResult<T> {
export interface QueryResult<T> {
data: T | undefined;
error: Error | undefined;
isLoading: boolean;

View file

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

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import type { HTMLLabelAttributes } from 'svelte/elements';
let { class: className, children, ...rest }: HTMLLabelAttributes = $props();
</script>
<label
class={cn(
'text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...rest}
>
{@render children?.()}
</label>

View file

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

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import type { HTMLTextareaAttributes } from 'svelte/elements';
let { value = $bindable(''), class: className, ...rest }: HTMLTextareaAttributes = $props();
</script>
<textarea
{...rest}
bind:value
class={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
></textarea>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import PlusIcon from '~icons/lucide/plus';
import { Collapsible } from 'melt/builders';
import { slide } from 'svelte/transition';
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label';
import XIcon from '~icons/lucide/x';
import { useConvexClient } from 'convex-svelte';
import { session } from '$lib/state/session.svelte';
import { useCachedQuery, type QueryResult } from '$lib/cache/cached-query.svelte';
import type { Doc } from '$lib/backend/convex/_generated/dataModel';
import { Input } from '$lib/components/ui/input';
import { api } from '$lib/backend/convex/_generated/api';
const client = useConvexClient();
const newRuleCollapsible = new Collapsible({
open: false,
});
let creatingRule = $state(false);
const userRulesQuery: QueryResult<Doc<'user_rules'>[]> = useCachedQuery(api.user_rules.all, {
sessionToken: session.current?.session.token ?? '',
});
async function submitNewRule(e: SubmitEvent) {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const name = formData.get('name');
const attach = formData.get('attach');
const rule = formData.get('rule');
if (rule === '' || !rule) return;
// cannot create rule with the same name
if (userRulesQuery.data?.findIndex((r) => r.name === name) !== -1) return;
creatingRule = true;
await client.mutation(api.user_rules.create, {
name,
attach,
rule,
sessionToken: session.current?.session.token ?? '',
});
creatingRule = false;
}
</script>
<svelte:head>
<title>Customization | Thom.chat</title>
</svelte:head>
<h1 class="text-2xl font-bold">Customization</h1>
<h2 class="text-muted-foreground mt-2 text-sm">Customize your experience with Thom.chat.</h2>
<div class="mt-8 flex flex-col gap-4">
<div class="flex place-items-center justify-between">
<h3 class="text-lg font-bold">Rules</h3>
<Button
{...newRuleCollapsible.trigger}
variant={newRuleCollapsible.open ? 'outline' : 'default'}
>
{#if newRuleCollapsible.open}
<XIcon class="size-4" />
{:else}
<PlusIcon class="size-4" />
{/if}
{newRuleCollapsible.open ? 'Cancel' : 'New Rule'}
</Button>
</div>
{#if newRuleCollapsible.open}
<div
{...newRuleCollapsible.content}
in:slide={{ duration: 150, axis: 'y' }}
class="bg-card flex flex-col gap-4 rounded-lg border p-4"
>
<div class="flex flex-col gap-1">
<h3 class="text-lg font-bold">New Rule</h3>
<p class="text-muted-foreground text-sm">
Create a new rule to customize the behavior of your AI.
</p>
</div>
<form onsubmit={submitNewRule} class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label for="name">Name (Used when referencing the rule)</Label>
<Input id="name" name="name" placeholder="My Rule" required />
</div>
<div class="flex flex-col gap-2">
<Label for="attach">Attach</Label>
<select
id="attach"
name="attach"
class="border-input bg-background h-9 w-fit rounded-md border px-2 pr-6 text-sm"
required
>
<option value="always">Always</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="flex flex-col gap-2">
<Label for="rule">Instructions</Label>
<Textarea id="rule" name="rule" placeholder="How should the AI respond?" required />
</div>
<div class="flex justify-end">
<Button loading={creatingRule} type="submit">Create Rule</Button>
</div>
</form>
</div>
{/if}
{#each userRulesQuery.data ?? [] as rule}
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold">{rule.name}</h3>
<p class="text-muted-foreground text-sm">{rule.rule}</p>
</div>
{/each}
</div>