Rule Tagging & Messaging improvements (#11)

This commit is contained in:
Aidan Bleser 2025-06-17 08:52:59 -05:00 committed by GitHub
parent bb9ec7095c
commit be7f93141a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 628 additions and 61 deletions

View file

@ -233,6 +233,18 @@
display: none; /* Chrome, Safari, and Opera */ display: none; /* Chrome, Safari, and Opera */
} }
@layer utilities {
.animation-delay-0 {
animation-delay: 0s;
}
.animation-delay-100 {
animation-delay: 0.1s;
}
.animation-delay-200 {
animation-delay: 0.2s;
}
}
@layer components { @layer components {
/* Modal is from DaisyUI */ /* Modal is from DaisyUI */
.modal { .modal {

View file

@ -34,6 +34,30 @@ export const get = query({
}, },
}); });
export const getById = query({
args: {
conversation_id: v.id('conversations'),
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const conversation = await ctx.db.get(args.conversation_id);
if (!conversation || conversation.user_id !== session.userId) {
throw new Error('Conversation not found or unauthorized');
}
return conversation;
},
});
export const create = mutation({ export const create = mutation({
args: { args: {
session_token: v.string(), session_token: v.string(),
@ -52,6 +76,7 @@ export const create = mutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
user_id: session.userId as any, user_id: session.userId as any,
updated_at: Date.now(), updated_at: Date.now(),
generating: true,
}); });
return res; return res;
@ -81,6 +106,7 @@ export const createAndAddMessage = mutation({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
user_id: session.userId as any, user_id: session.userId as any,
updated_at: Date.now(), updated_at: Date.now(),
generating: true,
}); });
const messageId = await ctx.runMutation(api.messages.create, { const messageId = await ctx.runMutation(api.messages.create, {
@ -125,6 +151,34 @@ export const updateTitle = mutation({
}, },
}); });
export const updateGenerating = mutation({
args: {
conversation_id: v.id('conversations'),
generating: v.boolean(),
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
// Verify the conversation belongs to the user
const conversation = await ctx.db.get(args.conversation_id);
if (!conversation || conversation.user_id !== session.userId) {
throw new Error('Conversation not found or unauthorized');
}
await ctx.db.patch(args.conversation_id, {
generating: args.generating,
updated_at: Date.now(),
});
},
});
export const togglePin = mutation({ export const togglePin = mutation({
args: { args: {
conversation_id: v.id('conversations'), conversation_id: v.id('conversations'),
@ -177,4 +231,3 @@ export const remove = mutation({
await ctx.db.delete(args.conversation_id); await ctx.db.delete(args.conversation_id);
}, },
}); });

View file

@ -50,26 +50,34 @@ export const create = mutation({
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
const messages = await ctx.runQuery(api.messages.getAllFromConversation, { // I think this just slows us down
conversation_id: args.conversation_id,
session_token: args.session_token,
});
const lastMessage = messages[messages.length - 1]; // const messages = await ctx.runQuery(api.messages.getAllFromConversation, {
// conversation_id: args.conversation_id,
// session_token: args.session_token,
// });
if (lastMessage?.role === args.role) { // const lastMessage = messages[messages.length - 1];
throw new Error('Last message has the same role, forbidden');
}
const id = await ctx.db.insert('messages', { // if (lastMessage?.role === args.role) {
conversation_id: args.conversation_id, // throw new Error('Last message has the same role, forbidden');
content: args.content, // }
role: args.role,
// Optional, coming from SK API route const [id] = await Promise.all([
model_id: args.model_id, ctx.db.insert('messages', {
provider: args.provider, conversation_id: args.conversation_id,
token_count: args.token_count, content: args.content,
}); role: args.role,
// Optional, coming from SK API route
model_id: args.model_id,
provider: args.provider,
token_count: args.token_count,
}),
ctx.db.patch(args.conversation_id as Id<'conversations'>, {
generating: true,
updated_at: Date.now(),
}),
]);
return id; return id;
}, },

View file

@ -46,6 +46,7 @@ export default defineSchema({
title: v.string(), title: v.string(),
updated_at: v.optional(v.number()), updated_at: v.optional(v.number()),
pinned: v.optional(v.boolean()), pinned: v.optional(v.boolean()),
generating: v.boolean(),
}).index('by_user', ['user_id']), }).index('by_user', ['user_id']),
messages: defineTable({ messages: defineTable({
conversation_id: v.string(), conversation_id: v.string(),

View file

@ -0,0 +1,73 @@
<!--
Installed from @ieedan/shadcn-svelte-extras
-->
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte';
import { cn } from '$lib/utils/utils';
import CheckIcon from '~icons/lucide/check';
import CopyIcon from '~icons/lucide/copy';
import XIcon from '~icons/lucide/x';
import { scale } from 'svelte/transition';
import type { CopyButtonProps } from './types';
let {
ref = $bindable(null),
text,
icon,
animationDuration = 500,
variant = 'ghost',
size = 'icon',
onCopy,
class: className,
tabindex = -1,
children,
...rest
}: CopyButtonProps = $props();
// this way if the user passes text then the button will be the default size
if (size === 'icon' && children) {
size = 'default';
}
const clipboard = new UseClipboard();
</script>
<Button
{...rest}
bind:ref
{variant}
{size}
{tabindex}
class={cn('flex items-center gap-2', className)}
type="button"
name="copy"
onclick={async () => {
const status = await clipboard.copy(text);
onCopy?.(status);
}}
>
{#if clipboard.status === 'success'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<CheckIcon tabindex={-1} />
<span class="sr-only">Copied</span>
</div>
{:else if clipboard.status === 'failure'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<XIcon tabindex={-1} />
<span class="sr-only">Failed to copy</span>
</div>
{:else}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
{#if icon}
{@render icon()}
{:else}
<CopyIcon tabindex={-1} />
{/if}
<span class="sr-only">Copy</span>
</div>
{/if}
{@render children?.()}
</Button>

View file

@ -0,0 +1,7 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import CopyButton from './copy-button.svelte';
export { CopyButton };

View file

@ -0,0 +1,20 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import type { Snippet } from 'svelte';
import type { ButtonPropsWithoutHTML } from '$lib/components/ui/button';
import type { UseClipboard } from '$lib/hooks/use-clipboard.svelte';
import type { HTMLAttributes } from 'svelte/elements';
export type CopyButtonPropsWithoutHTML = Pick<ButtonPropsWithoutHTML, 'size' | 'variant'> & {
ref?: HTMLButtonElement | null;
text: string;
icon?: Snippet<[]>;
animationDuration?: number;
onCopy?: (status: UseClipboard['status']) => void;
children?: Snippet<[]>;
};
export type CopyButtonProps = CopyButtonPropsWithoutHTML &
Omit<HTMLAttributes<HTMLButtonElement>, 'children'>;

View file

@ -2,13 +2,14 @@
import { cn } from '$lib/utils/utils'; import { cn } from '$lib/utils/utils';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
let { class: className, ...restProps }: HTMLInputAttributes = $props(); let { value = $bindable(''), class: className, ...restProps }: HTMLInputAttributes = $props();
</script> </script>
<input <input
class={cn( 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', 'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring aria-invalid:ring-destructive aria-invalid:border-destructive 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 className
)} )}
bind:value
{...restProps} {...restProps}
/> />

View file

@ -0,0 +1,87 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
type Options = {
/** The time before the copied status is reset. */
delay: number;
};
/** Use this hook to copy text to the clipboard and show a copied state.
*
* ## Usage
* ```svelte
* <script lang="ts">
* import { UseClipboard } from "$lib/hooks/use-clipboard.svelte";
*
* const clipboard = new UseClipboard();
* </script>
*
* <button onclick={clipboard.copy('Hello, World!')}>
* {#if clipboard.copied === 'success'}
* Copied!
* {:else if clipboard.copied === 'failure'}
* Failed to copy!
* {:else}
* Copy
* {/if}
* </button>
* ```
*
*/
export class UseClipboard {
#copiedStatus = $state<'success' | 'failure'>();
private delay: number;
private timeout: ReturnType<typeof setTimeout> | undefined = undefined;
constructor({ delay = 500 }: Partial<Options> = {}) {
this.delay = delay;
}
/** Copies the given text to the users clipboard.
*
* ## Usage
* ```ts
* clipboard.copy('Hello, World!');
* ```
*
* @param text
* @returns
*/
async copy(text: string) {
if (this.timeout) {
this.#copiedStatus = undefined;
clearTimeout(this.timeout);
}
try {
await navigator.clipboard.writeText(text);
this.#copiedStatus = 'success';
this.timeout = setTimeout(() => {
this.#copiedStatus = undefined;
}, this.delay);
} catch {
// an error can occur when not in the browser or if the user hasn't given clipboard access
this.#copiedStatus = 'failure';
this.timeout = setTimeout(() => {
this.#copiedStatus = undefined;
}, this.delay);
}
return this.#copiedStatus;
}
/** true when the user has just copied to the clipboard. */
get copied() {
return this.#copiedStatus === 'success';
}
/** Indicates whether a copy has occurred
* and gives a status of either `success` or `failure`. */
get status() {
return this.#copiedStatus;
}
}

View file

@ -29,14 +29,10 @@
async function submitNewRule(e: SubmitEvent) { async function submitNewRule(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement); const formData = new FormData(e.target as HTMLFormElement);
const name = formData.get('name') as string;
const attach = formData.get('attach') as 'always' | 'manual'; const attach = formData.get('attach') as 'always' | 'manual';
const rule = formData.get('rule') as string; const rule = formData.get('rule') as string;
if (rule === '' || !rule) return; if (rule === '' || !rule || ruleNameExists) return;
// cannot create rule with the same name
if (userRulesQuery.data?.findIndex((r) => r.name === name) !== -1) return;
creatingRule = true; creatingRule = true;
@ -47,10 +43,15 @@
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
}); });
newRuleCollapsible.open = false; newRuleCollapsible.open = false;
name = '';
creatingRule = false; creatingRule = false;
} }
let name = $state('');
const ruleNameExists = $derived(userRulesQuery.data?.findIndex((r) => r.name === name) !== -1);
</script> </script>
<svelte:head> <svelte:head>
@ -91,7 +92,14 @@
<form onsubmit={submitNewRule} class="flex flex-col gap-4"> <form onsubmit={submitNewRule} class="flex flex-col gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Label for="name">Name (Used when referencing the rule)</Label> <Label for="name">Name (Used when referencing the rule)</Label>
<Input id="name" name="name" placeholder="My Rule" required /> <Input
id="name"
name="name"
placeholder="My Rule"
required
bind:value={name}
aria-invalid={ruleNameExists}
/>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Label for="attach">Rule Type</Label> <Label for="attach">Rule Type</Label>

View file

@ -10,6 +10,7 @@ import OpenAI from 'openai';
import { waitUntil } from '@vercel/functions'; import { waitUntil } from '@vercel/functions';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import type { ChatCompletionSystemMessageParam } from 'openai/resources';
// Set to true to enable debug logging // Set to true to enable debug logging
const ENABLE_LOGGING = true; const ENABLE_LOGGING = true;
@ -46,7 +47,7 @@ async function generateConversationTitle({
session, session,
startTime, startTime,
keyResultPromise, keyResultPromise,
userMessage userMessage,
}: { }: {
conversationId: string; conversationId: string;
session: SessionObj; session: SessionObj;
@ -83,7 +84,7 @@ async function generateConversationTitle({
} }
const conversations = conversationResult.value; const conversations = conversationResult.value;
const conversation = conversations.find(c => c._id === conversationId); const conversation = conversations.find((c) => c._id === conversationId);
if (!conversation || !conversation.title.includes('Untitled')) { if (!conversation || !conversation.title.includes('Untitled')) {
log('Title generation: Conversation not found or already has custom title', startTime); log('Title generation: Conversation not found or already has custom title', startTime);
@ -152,16 +153,18 @@ async function generateAIResponse({
startTime, startTime,
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
rulesResultPromise,
}: { }: {
conversationId: string; conversationId: string;
session: SessionObj; session: SessionObj;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>; keyResultPromise: ResultAsync<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>; modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
}) { }) {
log('Starting AI response generation in background', startTime); log('Starting AI response generation in background', startTime);
const [modelResult, keyResult, messagesQueryResult] = await Promise.all([ const [modelResult, keyResult, messagesQueryResult, rulesResult] = await Promise.all([
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
ResultAsync.fromPromise( ResultAsync.fromPromise(
@ -171,6 +174,7 @@ async function generateAIResponse({
}), }),
(e) => `Failed to get messages: ${e}` (e) => `Failed to get messages: ${e}`
), ),
rulesResultPromise,
]); ]);
if (modelResult.isErr()) { if (modelResult.isErr()) {
@ -209,6 +213,35 @@ async function generateAIResponse({
log('Background: API key retrieved successfully', startTime); log('Background: API key retrieved successfully', startTime);
if (rulesResult.isErr()) {
log(`Background rules query failed: ${rulesResult.error}`, startTime);
return;
}
const userMessage = messages[messages.length - 1];
if (!userMessage) {
log('Background: No user message found', startTime);
return;
}
const attachedRules = [
...rulesResult.value.filter((r) => r.attach === 'always'),
...parseMessageForRules(
userMessage.content,
rulesResult.value.filter((r) => r.attach === 'manual')
),
];
log(`Background: ${attachedRules.length} rules attached`, startTime);
const systemMessage: ChatCompletionSystemMessageParam = {
role: 'system',
content: `The user may have mentioned one or more rules to follow with the @<rule_name> syntax. Please follow these rules.
Rules to follow:
${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
};
const openai = new OpenAI({ const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1', baseURL: 'https://openrouter.ai/api/v1',
apiKey: key, apiKey: key,
@ -217,7 +250,7 @@ async function generateAIResponse({
const streamResult = await ResultAsync.fromPromise( const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create({ openai.chat.completions.create({
model: model.model_id, model: model.model_id,
messages: messages.map((m) => ({ role: m.role, content: m.content })), messages: [...messages.map((m) => ({ role: m.role, content: m.content })), systemMessage],
max_tokens: 1000, max_tokens: 1000,
temperature: 0.7, temperature: 0.7,
stream: true, stream: true,
@ -283,6 +316,21 @@ async function generateAIResponse({
startTime startTime
); );
const updateGeneratingResult = await ResultAsync.fromPromise(
client.mutation(api.conversations.updateGenerating, {
conversation_id: conversationId as Id<'conversations'>,
generating: false,
session_token: session.token,
}),
(e) => `Failed to update generating status: ${e}`
);
if (updateGeneratingResult.isErr()) {
log(`Background generating status update failed: ${updateGeneratingResult.error}`, startTime);
return;
}
log('Background: Generating status updated to false', startTime);
} catch (error) { } catch (error) {
log(`Background stream processing error: ${error}`, startTime); log(`Background stream processing error: ${error}`, startTime);
} }
@ -348,6 +396,13 @@ export const POST: RequestHandler = async ({ request }) => {
(e) => `Failed to get API key: ${e}` (e) => `Failed to get API key: ${e}`
); );
const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, {
session_token: session.token,
}),
(e) => `Failed to get rules: ${e}`
);
log('Session authenticated successfully', startTime); log('Session authenticated successfully', startTime);
let conversationId = args.conversation_id; let conversationId = args.conversation_id;
@ -376,7 +431,7 @@ export const POST: RequestHandler = async ({ request }) => {
session, session,
startTime, startTime,
keyResultPromise, keyResultPromise,
userMessage: args.message userMessage: args.message,
}).catch((error) => { }).catch((error) => {
log(`Background title generation error: ${error}`, startTime); log(`Background title generation error: ${error}`, startTime);
}) })
@ -410,6 +465,7 @@ export const POST: RequestHandler = async ({ request }) => {
startTime, startTime,
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
rulesResultPromise,
}).catch((error) => { }).catch((error) => {
log(`Background AI response generation error: ${error}`, startTime); log(`Background AI response generation error: ${error}`, startTime);
}) })
@ -419,15 +475,15 @@ export const POST: RequestHandler = async ({ request }) => {
return response({ ok: true, conversation_id: conversationId }); return response({ ok: true, conversation_id: conversationId });
}; };
// function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] { function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] {
// const matchedRules: Doc<'user_rules'>[] = []; const matchedRules: Doc<'user_rules'>[] = [];
// for (const rule of rules) { for (const rule of rules) {
// const match = message.indexOf(`@${rule.name} `); const match = message.match(new RegExp(`@${rule.name}(\\s|$)`));
// if (match === -1) continue; if (!match) continue;
// matchedRules.push(rule); matchedRules.push(rule);
// } }
// return matchedRules; return matchedRules;
// } }

View file

@ -20,8 +20,12 @@
import { type Doc, type Id } from '$lib/backend/convex/_generated/dataModel.js'; import { type Doc, type Id } from '$lib/backend/convex/_generated/dataModel.js';
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
import Tooltip from '$lib/components/ui/tooltip.svelte'; import Tooltip from '$lib/components/ui/tooltip.svelte';
import { Popover } from 'melt/builders';
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { callModal } from '$lib/components/ui/modal/global-modal.svelte'; import { callModal } from '$lib/components/ui/modal/global-modal.svelte';
import { ElementSize } from 'runed';
import LoaderCircleIcon from '~icons/lucide/loader-circle';
import { cn } from '$lib/utils/utils.js';
const client = useConvexClient(); const client = useConvexClient();
@ -56,6 +60,10 @@
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
}); });
const rulesQuery = useCachedQuery(api.user_rules.all, {
session_token: session.current?.session.token ?? '',
});
const _autosize = new TextareaAutosize(); const _autosize = new TextareaAutosize();
function groupConversationsByTime(conversations: Doc<'conversations'>[]) { function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
@ -143,6 +151,106 @@
{ key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth }, { key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth },
{ key: 'older', label: 'Older', conversations: groupedConversations.older }, { key: 'older', label: 'Older', conversations: groupedConversations.older },
]); ]);
let message = $state('');
const suggestedRules = $derived.by(() => {
if (!rulesQuery.data || rulesQuery.data.length === 0) return;
if (!textarea) return;
const cursor = textarea.selectionStart;
const index = message.lastIndexOf('@', cursor);
if (index === -1) return;
const ruleFromCursor = message.slice(index + 1, cursor);
const suggestions: Doc<'user_rules'>[] = [];
for (const rule of rulesQuery.data) {
// on a match, don't show any suggestions
if (rule.name === ruleFromCursor) return;
if (rule.name.toLowerCase().startsWith(ruleFromCursor.toLowerCase())) {
suggestions.push(rule);
}
}
return suggestions.length > 0 ? suggestions : undefined;
});
const popover = new Popover();
function completeRule(rule: Doc<'user_rules'>) {
if (!textarea) return;
const cursor = textarea.selectionStart;
const index = message.lastIndexOf('@', cursor);
if (index === -1) return;
message = message.slice(0, index) + `@${rule.name}` + message.slice(cursor);
textarea.selectionStart = index + rule.name.length + 1;
textarea.selectionEnd = index + rule.name.length + 1;
popover.open = false;
}
function completeSelectedRule() {
if (!suggestedRules) return;
const rules = Array.from(ruleList.querySelectorAll('[data-list-item]'));
const activeIndex = rules.findIndex((r) => r.getAttribute('data-active') === 'true');
if (activeIndex === -1) return;
const rule = suggestedRules[activeIndex];
if (!rule) return;
completeRule(rule);
}
let ruleList = $state<HTMLDivElement>(null!);
function handleKeyboardNavigation(direction: 'up' | 'down') {
if (!suggestedRules) return;
const rules = Array.from(ruleList.querySelectorAll('[data-list-item]'));
let activeIndex = rules?.findIndex((r) => r.getAttribute('data-active') === 'true');
if (activeIndex === -1) {
if (!suggestedRules[0]) return;
rules[0]?.setAttribute('data-active', 'true');
return;
}
// don't loop
if (direction === 'up' && activeIndex === 0) {
return;
}
// don't loop
if (direction === 'down' && activeIndex === suggestedRules.length - 1) {
return;
}
rules[activeIndex]?.setAttribute('data-active', 'false');
if (direction === 'up') {
const newIndex = activeIndex - 1;
if (!suggestedRules[newIndex]) return;
rules[newIndex]?.setAttribute('data-active', 'true');
} else {
const newIndex = activeIndex + 1;
if (!suggestedRules[newIndex]) return;
rules[newIndex]?.setAttribute('data-active', 'true');
}
}
const textareaSize = new ElementSize(() => textarea);
</script> </script>
<svelte:head> <svelte:head>
@ -180,16 +288,26 @@
</div> </div>
{#each group.conversations as conversation (conversation._id)} {#each group.conversations as conversation (conversation._id)}
{@const isActive = page.params.id === conversation._id} {@const isActive = page.params.id === conversation._id}
<a href={`/chat/${conversation._id}`} class="group py-0.5 pr-2.5 text-left text-sm"> <a
<div class="relative overflow-clip"> href={`/chat/${conversation._id}`}
<p class="group w-full py-0.5 pr-2.5 text-left text-sm"
class={[ >
' truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap', <div
isActive ? 'bg-sidebar-accent' : 'group-hover:bg-sidebar-accent ', class={cn(
]} 'relative flex w-full items-center justify-between overflow-clip rounded-lg',
> { 'bg-sidebar-accent': isActive, 'group-hover:bg-sidebar-accent': !isActive }
)}
>
<p class="truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap">
<span>{conversation.title}</span> <span>{conversation.title}</span>
</p> </p>
<div class="pr-2">
{#if conversation.generating}
<div class="flex animate-[spin_0.75s_linear_infinite] place-items-center justify-center">
<LoaderCircleIcon class="size-4" />
</div>
{/if}
</div>
<div <div
class={[ class={[
'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0', 'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0',
@ -285,19 +403,80 @@
}} }}
bind:this={form} bind:this={form}
> >
{#if suggestedRules}
<div
{...popover.content}
class="bg-background border-border absolute rounded-lg border"
style="width: {textareaSize.width}px"
>
<div class="flex flex-col p-2" bind:this={ruleList}>
{#each suggestedRules as rule, i (rule._id)}
<button
type="button"
data-list-item
data-active={i === 0}
onmouseover={(e) => {
for (const rule of ruleList.querySelectorAll('[data-list-item]')) {
rule.setAttribute('data-active', 'false');
}
e.currentTarget.setAttribute('data-active', 'true');
}}
onfocus={(e) => {
for (const rule of ruleList.querySelectorAll('[data-list-item]')) {
rule.setAttribute('data-active', 'false');
}
e.currentTarget.setAttribute('data-active', 'true');
}}
onclick={() => completeRule(rule)}
class="data-[active=true]:bg-accent rounded-md px-2 py-1 text-start"
>
{rule.name}
</button>
{/each}
</div>
</div>
{/if}
<!-- TODO: Figure out better autofocus solution --> <!-- TODO: Figure out better autofocus solution -->
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<textarea <textarea
{...popover.trigger}
bind:this={textarea} bind:this={textarea}
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" 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..."
name="message" name="message"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey && !popover.open) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} }
if (e.key === 'Enter' && popover.open) {
e.preventDefault();
completeSelectedRule();
}
if (e.key === 'Escape' && popover.open) {
e.preventDefault();
popover.open = false;
}
if (e.key === 'ArrowUp' && popover.open) {
e.preventDefault();
handleKeyboardNavigation('up');
}
if (e.key === 'ArrowDown' && popover.open) {
e.preventDefault();
handleKeyboardNavigation('down');
}
if (e.key === '@' && !popover.open) {
popover.open = true;
}
}} }}
bind:value={message}
autofocus autofocus
autocomplete="off" autocomplete="off"
></textarea> ></textarea>

View file

@ -1,25 +1,39 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import type { Id } from '$lib/backend/convex/_generated/dataModel';
import { useCachedQuery } from '$lib/cache/cached-query.svelte'; import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { session } from '$lib/state/session.svelte'; import { session } from '$lib/state/session.svelte';
import LoadingDots from './loading-dots.svelte';
import Message from './message.svelte';
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({ const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
conversation_id: page.params.id ?? '', conversation_id: page.params.id ?? '',
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
})); }));
const conversation = useCachedQuery(api.conversations.getById, () => ({
conversation_id: page.params.id as Id<'conversations'>,
session_token: session.current?.session.token ?? '',
}));
const lastMessageHasContent = $derived.by(() => {
if (!messages.data) return false;
const lastMessage = messages.data[messages.data.length - 1];
if (!lastMessage) return false;
if (lastMessage.role !== 'assistant') return false;
return lastMessage.content.length > 0;
});
</script> </script>
<div class="flex h-full flex-1 flex-col overflow-x-clip overflow-y-auto py-4"> <div class="flex h-full flex-1 flex-col overflow-x-clip overflow-y-auto py-4">
{#each messages.data ?? [] as message (message._id)} {#each messages.data ?? [] as message (message._id)}
{#if message.role === 'user'} <Message {message} />
<div class="max-w-[80%] self-end bg-blue-900 p-2 text-white">
{message.content}
</div>
{:else if message.role === 'assistant'}
<div class="max-w-[80%] p-2 text-white">
{message.content}
</div>
{/if}
{/each} {/each}
{#if conversation.data?.generating && !lastMessageHasContent}
<LoadingDots />
{/if}
</div> </div>

View file

@ -0,0 +1,11 @@
<div class="flex place-items-center gap-2 p-2">
<div
class="bg-accent animation-delay-0 size-2.5 animate-[bounce_0.75s_ease-in-out_infinite] rounded-full"
></div>
<div
class="bg-accent animation-delay-100 size-2.5 animate-[bounce_0.75s_ease-in-out_infinite] rounded-full"
></div>
<div
class="bg-accent animation-delay-200 size-2.5 animate-[bounce_0.75s_ease-in-out_infinite] rounded-full"
></div>
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import { tv } from 'tailwind-variants';
import type { Doc } from '$lib/backend/convex/_generated/dataModel';
import { CopyButton } from '$lib/components/ui/copy-button';
const style = tv({
base: 'rounded-lg p-2',
variants: {
role: {
user: 'bg-primary text-primary-foreground self-end',
assistant: 'text-foreground',
},
},
});
type Props = {
message: Doc<'messages'>;
};
let { message }: Props = $props();
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0)}
<div class={cn('group flex max-w-[80%] flex-col gap-1', { 'self-end': message.role === 'user' })}>
<div class={style({ role: message.role })}>
{message.content}
</div>
<div
class={cn('flex place-items-center opacity-0 transition-opacity group-hover:opacity-100', {
'justify-end': message.role === 'user',
})}
>
<CopyButton class="size-7" text={message.content} />
</div>
</div>
{/if}