rules (#9)
This commit is contained in:
parent
ff54b6b641
commit
82d3d4f933
9 changed files with 232 additions and 11 deletions
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
55
src/lib/backend/convex/user_rules.ts
Normal file
55
src/lib/backend/convex/user_rules.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
4
src/lib/cache/cached-query.svelte.ts
vendored
4
src/lib/cache/cached-query.svelte.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
3
src/lib/components/ui/label/index.ts
Normal file
3
src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Label from './label.svelte';
|
||||
|
||||
export { Label };
|
||||
16
src/lib/components/ui/label/label.svelte
Normal file
16
src/lib/components/ui/label/label.svelte
Normal 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>
|
||||
3
src/lib/components/ui/textarea/index.ts
Normal file
3
src/lib/components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Textarea from './textarea.svelte';
|
||||
|
||||
export { Textarea };
|
||||
15
src/lib/components/ui/textarea/textarea.svelte
Normal file
15
src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
||||
120
src/routes/account/customization/+page.svelte
Normal file
120
src/routes/account/customization/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue