streaming, markdown, model selecting, and lots more

This commit is contained in:
Zoe
2026-02-02 23:16:06 -06:00
parent 8c28946703
commit d5a5945c03
114 changed files with 6109 additions and 2938 deletions
+46 -25
View File
@@ -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>
+33 -9
View File
@@ -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>
+93 -216
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">&nbsp;</span></h1>
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor">&nbsp;</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>