streaming, markdown, model selecting, and lots more
This commit is contained in:
@@ -1,37 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const { createTopic, fetchingTopics } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const creatingTopic = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
// create a new topic, and send the message to it
|
||||
console.log('handleSubmit', message);
|
||||
if (!activeAgent.value) return;
|
||||
const { createTopic, sendMessage } = useChat(route.params.id as string);
|
||||
const { getAgent } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
creatingTopic.value = true
|
||||
try {
|
||||
const newTopic = await createTopic('New Topic', activeAgent.value.id)
|
||||
// Navigate to the new topic
|
||||
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create topic:', error)
|
||||
} finally {
|
||||
creatingTopic.value = false
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
}
|
||||
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = await createTopic();
|
||||
if (!topic) throw new Error('Failed to create topic');
|
||||
|
||||
sendMessage(message, topic, [], agent.value!, model.provider, model);
|
||||
|
||||
return navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeModels?.()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full pb-4 justify-center">
|
||||
<div class="max-w-4xl h-full w-full flex flex-col">
|
||||
<!-- chat pane -->
|
||||
<div class="flex h-full flex-col gap-2 justify-end mb-28">
|
||||
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
|
||||
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="flex h-full max-w-4xl w-full flex-col gap-2 justify-end">
|
||||
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
||||
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
||||
:providers="providers" @submit="handleSubmit"></ChatInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
const { activeAgent: agent, updateAgent } = await useAgents();
|
||||
const route = useRoute()
|
||||
const { getAgent } = await useAgents();
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
// if (agent.value === undefined) navigateTo('/');
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const handleInput = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value.length === 0) {
|
||||
if (target.value.trimStart().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAgent(agent.value!.id, { name: target.value });
|
||||
await triplit.update('agents', agent.value.id, { name: target.value });
|
||||
};
|
||||
|
||||
const changeSystemPrompt = async (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
let value: string | undefined = target.value;
|
||||
|
||||
if (value.trimStart().length === 0) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
await triplit.update('agents', agent.value.id, {
|
||||
systemPrompt: target.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-14">
|
||||
<div class="flex flex-col gap-4 px-14 w-full h-full">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
|
||||
@@ -25,9 +48,10 @@ const handleInput = (e: Event) => {
|
||||
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
|
||||
type="text" :value="agent?.name" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[var(--color-subtle)]">Agent ID:</span>
|
||||
<span class="text-sm font-semibold">{{ route.params.id }}</span>
|
||||
<div class="flex items-center gap-2 w-full h-full mb-14">
|
||||
<textarea placeholder="System Message..."
|
||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
|
||||
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,235 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~~/types';
|
||||
import type { Message } from '~/composables/useChat';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const chatPane = ref<HTMLElement | null>(null);
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
const { activeTopic, topicsForActiveAgent } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
const { sendMessage } = useChat(route.params.id as string);
|
||||
const { getAgent } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
const loading = ref(false);
|
||||
const messages = useState<Message[]>('messages', () => []);
|
||||
const generatingMessage = ref('');
|
||||
|
||||
// Fetch messages for this topic
|
||||
if (activeTopic.value) {
|
||||
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`);
|
||||
if (topicData.error.value) {
|
||||
console.error('Failed to load topic:', topicData.error.value);
|
||||
} else if (topicData.data.value?.messages) {
|
||||
messages.value = topicData.data.value.messages;
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
if (!activeTopic.value) {
|
||||
console.error('No active topic');
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
const topicQuery = computed(() =>
|
||||
triplit
|
||||
.query('topics')
|
||||
.Where(['id', '=', route.params.topicId])
|
||||
.Include('generations')
|
||||
.Include('messages', (rel) =>
|
||||
rel('messages')
|
||||
.Include('generation')
|
||||
.Include('parts', (rel) => rel('parts').Include('toolCall')),
|
||||
)
|
||||
.Limit(1)
|
||||
);
|
||||
|
||||
const { results, unsubscribe: unsubscribeTopic } = await useQuery('topic', triplit, topicQuery);
|
||||
|
||||
const topic = computed(() => {
|
||||
if (results.value?.length === 0) return null;
|
||||
|
||||
// copy messages to a mutable object and sort by createdAt
|
||||
const messages = results!.value![0]!.messages.map((message) => ({
|
||||
...message,
|
||||
parts: message.parts.map((part) => ({
|
||||
...part,
|
||||
toolCall: part.toolCall ? { ...part.toolCall } : null,
|
||||
})),
|
||||
}));
|
||||
messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
|
||||
// for each message, sort parts by createdAt
|
||||
messages.forEach((message) => {
|
||||
message.parts = message.parts
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.filter((part) => part.content !== '' || part.toolCall !== null);
|
||||
});
|
||||
return { ...results.value![0]!, messages };
|
||||
});
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
if (topic.value === null) return null;
|
||||
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
||||
});
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(chatPane);
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
|
||||
const handleCancel = async () => {
|
||||
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Add user message to local state
|
||||
const userMessage: Message = {
|
||||
id: `temp_${Date.now()}`,
|
||||
topicId: activeTopic.value.id,
|
||||
userId: '',
|
||||
content: message,
|
||||
isUser: true,
|
||||
regeneratedFromId: null,
|
||||
isRegenerated: false,
|
||||
editedAt: null,
|
||||
createdAt: new Date() as any
|
||||
};
|
||||
messages.value.push(userMessage);
|
||||
|
||||
// Create generation
|
||||
const generation = await $fetch(`/api/chat/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topicId: activeTopic.value.id,
|
||||
messages: messages.value
|
||||
.filter(m => m.content)
|
||||
.map(m => ({
|
||||
type: m.isUser ? 'user' : 'agent',
|
||||
message: m.content
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
appState.startGeneration(generation.generationId);
|
||||
|
||||
// Stream the response
|
||||
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||
method: 'get',
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||
generatingMessage.value = '';
|
||||
let hasError = false;
|
||||
|
||||
while (!hasError) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
try {
|
||||
const lines = value.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.type === 'start') {
|
||||
generatingMessage.value = '';
|
||||
} else if (event.type === 'token') {
|
||||
generatingMessage.value += event.data;
|
||||
} else if (event.type === 'complete') {
|
||||
// Add the completed message to the list
|
||||
console.log(event, event.data);
|
||||
if (event.data) {
|
||||
messages.value.concat(event.data);
|
||||
generatingMessage.value = '';
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
console.error('Generation error:', event.data);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse event:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
appState.endGeneration();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit message:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await sendMessage(message, topic.value!, topic.value!.messages as unknown as Message[], agent.value!, model.provider, model);
|
||||
scrollToBottom('instant');
|
||||
};
|
||||
|
||||
const handleRegenerate = async (messageId: string) => {
|
||||
if (!activeTopic.value) return;
|
||||
|
||||
// Find the user message before this one to regenerate context
|
||||
const messageIndex = messages.value.findIndex(m => m.id === messageId);
|
||||
if (messageIndex === -1) return;
|
||||
|
||||
const previousUserMessage = messages.value[messageIndex - 1];
|
||||
if (!previousUserMessage) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Create generation with previous user message
|
||||
const generation = await $fetch(`/api/chat/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topicId: activeTopic.value.id,
|
||||
regeneratesFrom: messageId,
|
||||
messages: messages.value.slice(0, messageIndex)
|
||||
.map(m => ({
|
||||
type: m.isUser ? 'user' : 'agent',
|
||||
message: m.content
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
appState.startGeneration(generation.generationId);
|
||||
|
||||
// Stream the response
|
||||
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||
method: 'get',
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||
generatingMessage.value = '';
|
||||
let hasError = false;
|
||||
|
||||
while (!hasError) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
try {
|
||||
const lines = value.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.type === 'start') {
|
||||
generatingMessage.value = '';
|
||||
} else if (event.type === 'token') {
|
||||
generatingMessage.value += event.data;
|
||||
} else if (event.type === 'complete') {
|
||||
// Add the new regenerated message
|
||||
if (event.data) {
|
||||
messages.value.push(event.data);
|
||||
generatingMessage.value = '';
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
console.error('Generation error:', event.data);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse event:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
appState.endGeneration();
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRegeneration = (messageId: string) => {
|
||||
const index = messages.value.findIndex(m => m.id === messageId);
|
||||
if (index === -1) return;
|
||||
|
||||
// In a real app, you'd update the UI to show the selected version
|
||||
// For now, just highlight it
|
||||
console.log('Selected regeneration:', messageId);
|
||||
};
|
||||
|
||||
const handleDelete = (messageId: string) => {
|
||||
const index = messages.value.findIndex(m => m.id === messageId);
|
||||
if (index === -1) return;
|
||||
|
||||
messages.value.splice(index, 1);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
unsubscribeTopic?.();
|
||||
unsubscribeModels?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full pb-4 justify-center">
|
||||
<div class="max-w-4xl h-full w-full flex flex-col">
|
||||
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
|
||||
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
|
||||
@select="handleSelectRegeneration" @delete="handleDelete" />
|
||||
|
||||
<div v-if="generatingMessage" class="flex gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg bg-[var(--color-neutral)] border border-[var(--color-highlight)] flex items-center justify-center">
|
||||
<Icon name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-[var(--color-neutral)]">Agent</p>
|
||||
<p class="text-sm text-[var(--color-text)]">{{ generatingMessage }}</p>
|
||||
</div>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
|
||||
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||
<Message v-for="message in topic.messages" :key="message.id" :message="message" />
|
||||
</div>
|
||||
|
||||
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
|
||||
No messages yet. Start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChatInput @submit="handleSubmit" :loading="loading" />
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent" :providers="providers"
|
||||
@submit="handleSubmit" @cancel="handleCancel"></ChatInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
+65
-53
@@ -1,47 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
})
|
||||
});
|
||||
|
||||
const { signIn, session, fetchSession, authClient } = useAuth();
|
||||
|
||||
if (session.value !== null) {
|
||||
navigateTo("/");
|
||||
}
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let tempForm = {
|
||||
email: "",
|
||||
password: "",
|
||||
}
|
||||
const tempForm = {
|
||||
email: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
// prevent text fields from clearing on hydration
|
||||
onBeforeMount(() => {
|
||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
||||
})
|
||||
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
let hydrated = ref(false);
|
||||
const hydrated = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
form.email = tempForm.email;
|
||||
form.password = tempForm.password;
|
||||
hydrated.value = true;
|
||||
|
||||
emailInputEl.value!.addEventListener("input", () => {
|
||||
emailInputEl.value!.setCustomValidity("");
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
passwordInputEl.value!.addEventListener("input", () => {
|
||||
passwordInputEl.value!.setCustomValidity("");
|
||||
passwordInputEl.value!.addEventListener('input', () => {
|
||||
passwordInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,40 +56,52 @@ const submit = async () => {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await signIn.email({
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
await fetchSession();
|
||||
navigateTo("/")
|
||||
},
|
||||
onError: (ctx) => {
|
||||
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (error) {
|
||||
case "INVALID_PASSWORD":
|
||||
passwordInputEl.value!.setCustomValidity(ctx.error.message);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
case "ACCOUNT_NOT_FOUND":
|
||||
case "USER_NOT_FOUND":
|
||||
case "USER_EMAIL_NOT_FOUND":
|
||||
emailInputEl.value!.setCustomValidity(ctx.error.message);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(ctx.error);
|
||||
alert(`Something went wrong. ${ctx.error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (errorCode) {
|
||||
case 'INVALID_PASSWORD':
|
||||
passwordInputEl.value!.setCustomValidity(error.message!);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'ACCOUNT_NOT_FOUND':
|
||||
case 'USER_NOT_FOUND':
|
||||
case 'USER_EMAIL_NOT_FOUND':
|
||||
emailInputEl.value!.setCustomValidity(error.message!);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(error);
|
||||
alert(`Something went wrong. ${error.message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
// force a session refetch
|
||||
clearNuxtData();
|
||||
|
||||
// success
|
||||
const triplit = useTriplitClient();
|
||||
if ('startSession' in triplit) {
|
||||
await triplit.startSession(data.token);
|
||||
}
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,9 +114,10 @@ const submit = async () => {
|
||||
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
||||
autocomplete="current-password" id="password" v-model="form.password" />
|
||||
<button :disabled="!hydrated" class="accent" type="submit">
|
||||
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
|
||||
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
|
||||
<span v-else>Login</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-center">Dont have an account? <a href="/auth/register">Register</a></p>
|
||||
<p class="text-center">Dont have an account? <a
|
||||
:href="to ? `/auth/register?to=${to}` : '/auth/register'">Register</a></p>
|
||||
</template>
|
||||
+83
-68
@@ -1,47 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
})
|
||||
});
|
||||
|
||||
const { signUp, session, fetchSession, authClient } = useAuth();
|
||||
const { session } = await useAuth();
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
if (session.value !== null) {
|
||||
navigateTo("/");
|
||||
await navigateTo(to ?? '/');
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
if (process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1") {
|
||||
navigateTo("/auth/login")
|
||||
if (process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1') {
|
||||
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
let nameInputEl = ref<HTMLInputElement | null>(null);
|
||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let tempForm = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
}
|
||||
const tempForm = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
// prevent text fields from clearing on hydration
|
||||
onBeforeMount(() => {
|
||||
tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
|
||||
})
|
||||
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
@@ -52,25 +56,24 @@ onMounted(() => {
|
||||
form.confirmPassword = tempForm.confirmPassword;
|
||||
hydrated.value = true;
|
||||
|
||||
nameInputEl.value!.addEventListener("input", () => {
|
||||
nameInputEl.value!.setCustomValidity("");
|
||||
nameInputEl.value!.addEventListener('input', () => {
|
||||
nameInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
emailInputEl.value!.addEventListener("input", () => {
|
||||
emailInputEl.value!.setCustomValidity("");
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
passwordInputEl.value!.addEventListener("input", () => {
|
||||
passwordInputEl.value!.setCustomValidity("");
|
||||
passwordInputEl.value!.addEventListener('input', () => {
|
||||
passwordInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
|
||||
confirmPasswordInputEl.value!.addEventListener("input", () => {
|
||||
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
||||
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
|
||||
confirmPasswordInputEl.value!.setCustomValidity("Passwords do not match");
|
||||
confirmPasswordInputEl.value!.setCustomValidity('Passwords do not match');
|
||||
confirmPasswordInputEl.value!.reportValidity();
|
||||
} else {
|
||||
confirmPasswordInputEl.value!.setCustomValidity("");
|
||||
confirmPasswordInputEl.value!.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -86,50 +89,62 @@ const submit = async () => {
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
alert("Passwords do not match")
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await signUp.email({
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
await fetchSession();
|
||||
navigateTo("/")
|
||||
},
|
||||
onError: (ctx) => {
|
||||
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (error) {
|
||||
case "USER_ALREADY_EXISTS":
|
||||
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
|
||||
emailInputEl.value!.setCustomValidity("Account with this email already exists");
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case "INVALID_EMAIL":
|
||||
emailInputEl.value!.setCustomValidity(ctx.error.message);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case "INVALID_PASSWORD":
|
||||
passwordInputEl.value!.setCustomValidity(ctx.error.message);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(ctx.error);
|
||||
alert("Something went wrong")
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (errorCode) {
|
||||
case 'USER_ALREADY_EXISTS':
|
||||
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
|
||||
emailInputEl.value!.setCustomValidity('Account with this email already exists');
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVALID_EMAIL':
|
||||
emailInputEl.value!.setCustomValidity(error.message!);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVALID_PASSWORD':
|
||||
passwordInputEl.value!.setCustomValidity(error.message!);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(error);
|
||||
alert('Something went wrong: ' + error.message);
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
// force a session refetch
|
||||
clearNuxtData();
|
||||
|
||||
// success
|
||||
const triplit = useTriplitClient();
|
||||
if ('startSession' in triplit) {
|
||||
await triplit.startSession(data.token!);
|
||||
}
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,5 +166,5 @@ const submit = async () => {
|
||||
<span v-else>Register</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-center">Already have an account? <a href="/auth/login">Login</a></p>
|
||||
<p class="text-center">Already have an account? <a :href="to ? `/auth/login?to=${to}` : '/auth/login'">Login</a></p>
|
||||
</template>
|
||||
+62
-40
@@ -1,44 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user.value === null) {
|
||||
navigateTo("/auth/login");
|
||||
}
|
||||
const { agents } = await useAgents();
|
||||
|
||||
const taglines = {
|
||||
morning: [
|
||||
"crush your goals before breakfast",
|
||||
"turn your productivity to 11",
|
||||
"start your day like a boss",
|
||||
"make it happen before noon",
|
||||
"rise and grind, repeat",
|
||||
"morning MVP, all day",
|
||||
"fuel your fire early",
|
||||
"own the first half of your day"
|
||||
'crush your goals before breakfast',
|
||||
'turn your productivity to 11',
|
||||
'start your day like a boss',
|
||||
'make it happen before noon',
|
||||
'rise and grind, repeat',
|
||||
'morning MVP, all day',
|
||||
'fuel your fire early',
|
||||
'own the first half of your day',
|
||||
],
|
||||
afternoon: [
|
||||
"afternoon focus session",
|
||||
"making afternoon moves",
|
||||
"steady progress continues",
|
||||
"keeping the flow going",
|
||||
"afternoon productivity boost",
|
||||
"momentum is building",
|
||||
"making it happen today",
|
||||
"afternoon hustle mode"
|
||||
'afternoon focus session',
|
||||
'making afternoon moves',
|
||||
'steady progress continues',
|
||||
'keeping the flow going',
|
||||
'afternoon productivity boost',
|
||||
'momentum is building',
|
||||
'making it happen today',
|
||||
'afternoon hustle mode',
|
||||
],
|
||||
evening: [
|
||||
"finish strong today",
|
||||
"end on a high note",
|
||||
"wrap it up like a pro",
|
||||
"leave it all on the field",
|
||||
'finish strong today',
|
||||
'end on a high note',
|
||||
'wrap it up like a pro',
|
||||
'leave it all on the field',
|
||||
"tomorrow's success starts tonight",
|
||||
"last call for wins",
|
||||
"close it out like a champion",
|
||||
"seal the deal before bed"
|
||||
]
|
||||
}
|
||||
'last call for wins',
|
||||
'close it out like a champion',
|
||||
'seal the deal before bed',
|
||||
],
|
||||
};
|
||||
|
||||
const animatedText = ref("");
|
||||
const animatedText = ref('');
|
||||
const currentTaglineIndex = ref(0);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
@@ -55,7 +51,7 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
||||
}
|
||||
} else {
|
||||
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
|
||||
if (animatedText.value === "") {
|
||||
if (animatedText.value === '') {
|
||||
isDeleting.value = false;
|
||||
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
|
||||
}
|
||||
@@ -69,22 +65,23 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
||||
setTimeout(() => typeWriter(time), speed);
|
||||
};
|
||||
|
||||
const handleChatSubmit = (message: string) => {
|
||||
console.log('Message submitted:', message);
|
||||
const handleChatSubmit = async (message: string, _model: unknown) => {
|
||||
console.log('Message submitted:', message, agents);
|
||||
await navigateTo(`/agent/${agents.value![0]!.id}`);
|
||||
// TODO: Implement chat functionality
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
let time: 'morning' | 'afternoon' | 'evening' = "morning";
|
||||
let time: 'morning' | 'afternoon' | 'evening' = 'morning';
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
|
||||
if (hours < 12) {
|
||||
time = "morning";
|
||||
time = 'morning';
|
||||
} else if (hours < 22) {
|
||||
time = "afternoon";
|
||||
time = 'afternoon';
|
||||
} else {
|
||||
time = "evening";
|
||||
time = 'evening';
|
||||
}
|
||||
|
||||
// randomly select a tagline
|
||||
@@ -95,9 +92,34 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
||||
<h1 class="text-center">{{ animatedText }}<span class="cursor"> </span></h1>
|
||||
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor"> </span></h1>
|
||||
<div class="max-w-4xl h-full w-full">
|
||||
<ChatInput @submit="handleChatSubmit" />
|
||||
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" @submit="handleChatSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 0.9em;
|
||||
background-color: currentColor;
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user