streaming, markdown, model selecting, and lots more
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user