Rule Tagging & Messaging improvements (#11)
This commit is contained in:
parent
bb9ec7095c
commit
be7f93141a
15 changed files with 628 additions and 61 deletions
12
src/app.css
12
src/app.css
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
73
src/lib/components/ui/copy-button/copy-button.svelte
Normal file
73
src/lib/components/ui/copy-button/copy-button.svelte
Normal 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>
|
||||||
7
src/lib/components/ui/copy-button/index.ts
Normal file
7
src/lib/components/ui/copy-button/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CopyButton from './copy-button.svelte';
|
||||||
|
|
||||||
|
export { CopyButton };
|
||||||
20
src/lib/components/ui/copy-button/types.ts
Normal file
20
src/lib/components/ui/copy-button/types.ts
Normal 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'>;
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
87
src/lib/hooks/use-clipboard.svelte.ts
Normal file
87
src/lib/hooks/use-clipboard.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
// }
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
11
src/routes/chat/[id]/loading-dots.svelte
Normal file
11
src/routes/chat/[id]/loading-dots.svelte
Normal 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>
|
||||||
37
src/routes/chat/[id]/message.svelte
Normal file
37
src/routes/chat/[id]/message.svelte
Normal 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}
|
||||||
Loading…
Add table
Reference in a new issue