continue scaffolding and refine the basic foundation

This commit is contained in:
Zoe
2026-01-13 19:56:24 +00:00
parent 0877cc10bd
commit 8c28946703
37 changed files with 1431 additions and 509 deletions
+20 -76
View File
@@ -1,77 +1,24 @@
<script setup lang="ts">
import type { Message } from '~~/types'
const { createTopic, fetchingTopics } = await useTopics();
const { activeAgent } = await useAgents();
const { createTopic, activeTopic } = await useTopics()
const { activeAgent } = await useAgents()
const loading = ref(false)
const messages = useState<Message[]>('messages', () => [])
const generatingMessage = ref('')
if (activeTopic.value !== undefined) {
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`)
if (topicData.error.value) throw topicData.error
messages.value = topicData.data.value!.messages
console.log(messages.value)
}
const creatingTopic = ref(false);
const handleSubmit = async (message: string) => {
loading.value = true
let topic;
if (activeTopic.value) {
topic = activeTopic.value
} else {
topic = await createTopic('New Topic', activeAgent.value!.id)
// create a new topic, and send the message to it
console.log('handleSubmit', message);
if (!activeAgent.value) return;
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
}
console.log(topic.id)
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: topic.id,
messages: [{
type: 'user',
message
}]
})
})
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
})
// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
const reader = response.pipeThrough(new TextDecoderStream()).getReader()
generatingMessage.value = ''
// Read the data from the stream and update the UI
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const { type, data } = JSON.parse(value)
if (type === 'token') {
generatingMessage.value += data
continue
}
if (type === 'complete') {
if (generatingMessage.value !== data) {
generatingMessage.value = data
}
break
}
}
loading.value = false
}
</script>
@@ -79,15 +26,12 @@ const handleSubmit = async (message: string) => {
<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-6">
<p v-if="activeTopic !== undefined" v-for="message in messages" :key="message.id">
{{ message.content }}
</p>
<p v-else>No messages yet</p>
<p v-if="generatingMessage" class="text-sm text-gray-500">{{ generatingMessage }}</p>
<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>
<ChatInput @submit="handleSubmit" :loading="loading" />
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
</div>
</div>
</template>
+1 -1
View File
@@ -2,7 +2,7 @@
const { activeAgent: agent, updateAgent } = await useAgents();
const route = useRoute()
if (agent.value === undefined) navigateTo('/');
// if (agent.value === undefined) navigateTo('/');
const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement;
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import type { Message } from '~~/types';
const route = useRoute();
const appState = useAppState();
const { activeTopic, topicsForActiveAgent } = await useTopics();
const { activeAgent } = await useAgents();
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 handleSubmit = async (message: string) => {
if (!activeTopic.value) {
console.error('No active topic');
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;
}
};
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);
};
</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>
<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>
</div>
</template>
+19 -4
View File
@@ -10,17 +10,32 @@ if (session.value !== null) {
}
const form = reactive({
name: "",
email: "",
password: "",
confirmPassword: "",
});
const loading = ref(false);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let 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 ?? "";
})
let hydrated = ref(false);
onMounted(() => {
form.email = tempForm.email;
form.password = tempForm.password;
hydrated.value = true;
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
});
@@ -87,8 +102,8 @@ const submit = async () => {
<label for="password">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="current-password" id="password" v-model="form.password" />
<button class="accent" type="submit">
<iconify-icons v-if="loading" width="24" icon="svg-spinners:90-ring-with-bg" />
<button :disabled="!hydrated" class="accent" type="submit">
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
<span v-else>Login</span>
</button>
</form>
+24 -1
View File
@@ -28,7 +28,30 @@ let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
let 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 ?? "";
})
const hydrated = ref(false);
onMounted(() => {
form.name = tempForm.name;
form.email = tempForm.email;
form.password = tempForm.password;
form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true;
nameInputEl.value!.addEventListener("input", () => {
nameInputEl.value!.setCustomValidity("");
});
@@ -123,7 +146,7 @@ const submit = async () => {
<label for="confirmPassword">Confirm Password</label>
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword" />
<button class="accent" type="submit">
<button :disabled="!hydrated" class="accent" type="submit">
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
<span v-else>Register</span>
</button>
+3 -1
View File
@@ -96,6 +96,8 @@ 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>
<ChatInput @submit="handleChatSubmit" />
<div class="max-w-4xl h-full w-full">
<ChatInput @submit="handleChatSubmit" />
</div>
</div>
</template>