336 lines
12 KiB
Vue
336 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
|
|
import type { ModelWithProvider } from '~/composables/useModels';
|
|
import { buildFocusedMessageTree } from '~~/utils/message';
|
|
|
|
const rootStart = Date.now();
|
|
|
|
const chatPaneWrapper = ref<HTMLElement | null>(null);
|
|
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
|
|
const route = useRoute();
|
|
const { getAgent, deleteTopic, forkTopic } = await useAgents();
|
|
const { open: sidebarOpen, openSidebar } = useSidebar();
|
|
const { providers, allModels } = await useModels();
|
|
const { addShortcut } = useKeyboardShortcuts();
|
|
|
|
const agent = getAgent(route.params.id as string);
|
|
|
|
const topicId = computed(() => route.params.topicId as string);
|
|
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally, deleteMessagesFocusedDescendant } = await useChat(topicId);
|
|
|
|
watch(() => topic.value?.name, (newTopicName) => {
|
|
if (newTopicName !== undefined) {
|
|
useHead({ title: `${newTopicName} | Veridian` });
|
|
}
|
|
}, { immediate: true });
|
|
|
|
const submitMessage = async (message: BaseMessage, model: ModelWithProvider | null) => {
|
|
if (!model) {
|
|
console.error('No model selected');
|
|
return;
|
|
}
|
|
|
|
inputValue.value = { content: '', fileIds: [] };
|
|
|
|
const res = await sendMessage(message, async () => {
|
|
await nextTick();
|
|
setTimeout(() => {
|
|
scrollToBottom('instant')
|
|
});
|
|
});
|
|
if (!res.ok) {
|
|
console.error('Failed to send message:', res.error);
|
|
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
|
console.log("chat input", chatInput, message);
|
|
if (chatInput) {
|
|
inputValue.value = message;
|
|
nextTick(() => {
|
|
chatInput.focus();
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
startGeneration(model);
|
|
};
|
|
|
|
const handleAddMessage = async (message: BaseMessage, role: 'user' | 'assistant', _model: ModelWithProvider | null) => {
|
|
inputValue.value = { content: '', fileIds: [] };
|
|
|
|
const res = await sendMessage(message, async () => {
|
|
await nextTick();
|
|
setTimeout(() => {
|
|
scrollToBottom('instant');
|
|
});
|
|
}, role);
|
|
if (!res.ok) {
|
|
console.error('Failed to add message:', res.error);
|
|
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
|
if (chatInput) {
|
|
inputValue.value = message;
|
|
nextTick(() => {
|
|
chatInput.focus();
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRegenerate = async (message: Message) => {
|
|
if (!agent.value!.defaultModelId) {
|
|
console.error('No model selected');
|
|
return;
|
|
}
|
|
|
|
let messageId: string;
|
|
if (message.activeChildId && message.children.length > 0) {
|
|
messageId = message.activeChildId;
|
|
} else {
|
|
messageId = message.id;
|
|
}
|
|
|
|
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
|
|
if (!model || !model.provider) {
|
|
console.error('Model not found or provider not found');
|
|
return;
|
|
}
|
|
|
|
const res = await regenerateMessage(messageId, buildFocusedMessageTree(topic.value!.messages), model);
|
|
if (!res.ok) {
|
|
console.error('Failed to regenerate message:', ChatErrorType[res.error]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const flatMessages = computed(() => {
|
|
const messages: Message[] = [];
|
|
|
|
for (const message of topic.value?.messages ?? []) {
|
|
messages.push(message);
|
|
if (message.children.length > 0) {
|
|
messages.push(...message.children as Message[]);
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
});
|
|
|
|
const handleFork = async (message: Message) => {
|
|
const messageId = message.activeChildId && message.children.length > 0
|
|
? message.activeChildId
|
|
: message.id;
|
|
|
|
const newTopicId = await forkTopic(topicId.value, messageId);
|
|
if (newTopicId) {
|
|
await navigateTo(`/agent/${route.params.id}/topic/${newTopicId}`);
|
|
} else {
|
|
console.error('Failed to fork topic');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (rootMessage: Message) => {
|
|
let messageId: string;
|
|
if (rootMessage.activeChildId && rootMessage.children.length > 0) {
|
|
messageId = rootMessage.activeChildId;
|
|
} else {
|
|
messageId = rootMessage.id;
|
|
}
|
|
|
|
const result = await deleteMessagesFocusedDescendant(messageId);
|
|
if (!result.ok) {
|
|
console.error('Failed to delete message:', result.error);
|
|
return;
|
|
}
|
|
|
|
if (flatMessages.value.length === 0) {
|
|
await navigateTo(`/agent/${route.params.id}`);
|
|
deleteTopic(route.params.id as string, topicId.value);
|
|
}
|
|
}
|
|
|
|
const { scrollToBottom, isAtBottom } = useAutoScroll(chatPaneWrapper);
|
|
|
|
const handleResize = () => {
|
|
if (!isAtBottom()) return;
|
|
|
|
nextTick().then(() => {
|
|
requestAnimationFrame(() => {
|
|
scrollToBottom('instant');
|
|
});
|
|
});
|
|
};
|
|
|
|
const activeGeneration = computed(() => {
|
|
if (topic.value === null) return null;
|
|
const generations = flatMessages.value.flatMap(message => message.generation);
|
|
return generations?.find((generation) => generation?.status === 'pending') ?? null;
|
|
});
|
|
|
|
addShortcut(['ctrl', 'alt', 'n'], (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
(document.activeElement as HTMLElement | undefined)?.blur();
|
|
return navigateTo(`/agent/${route.params.id}`);
|
|
});
|
|
|
|
addShortcut(['ctrl', 'shift', 'enter'], (event) => {
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleRegenerate(lastMessage);
|
|
}
|
|
});
|
|
|
|
addShortcut(['ctrl', 'shift', 'backspace'], (event) => {
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleDelete(lastMessage);
|
|
}
|
|
});
|
|
|
|
addShortcut(['ctrl', 'shift', 'c'], (event) => {
|
|
if (activeGeneration.value === null) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleCancel();
|
|
})
|
|
|
|
addShortcut(['alt', '['], async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage && lastMessage.children.length > 0) {
|
|
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
|
const prevIdx = Math.max(0, currentIdx);
|
|
if (currentIdx > 0) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[prevIdx - 1]!.id });
|
|
}
|
|
}
|
|
})
|
|
|
|
addShortcut(['alt', ']'], async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage && lastMessage.children.length > 0) {
|
|
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
|
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
|
|
const nextIdx = Math.min(maxIdx, currentIdx + 1);
|
|
|
|
if (lastMessage.deleted) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
|
|
} else {
|
|
if (nextIdx === 0) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: null });
|
|
} else {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage && lastMessage.children.length > 0) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
|
if (currentIdx > 0) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[currentIdx - 1]!.id });
|
|
}
|
|
}
|
|
})
|
|
|
|
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
|
|
const lastMessage = topic.value?.messages?.at(-1);
|
|
if (lastMessage && lastMessage.children.length > 0) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
|
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
|
|
const nextIdx = Math.min(maxIdx, currentIdx + 1);
|
|
|
|
if (lastMessage.deleted) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
|
|
} else {
|
|
if (nextIdx === 0) {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: null });
|
|
} else {
|
|
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
addShortcut(['ctrl', 'alt', 'backspace'], async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
await navigateTo(`/agent/${route.params.id}`);
|
|
deleteTopic(route.params.id as string, topicId.value);
|
|
})
|
|
|
|
onMounted(() => {
|
|
scrollToBottom('instant');
|
|
});
|
|
|
|
const handleCancel = async () => {
|
|
// TODO: cancel generation
|
|
await $fetch(`/api/topic/${topicId.value}/chat/cancel/${activeGeneration.value?.id}`, {
|
|
method: 'POST',
|
|
});
|
|
};
|
|
|
|
console.log("full page render took", Date.now() - rootStart);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col h-full w-full max-w-full overflow-x-hidden">
|
|
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
|
|
<div class="flex items-center gap-2 max-w-full">
|
|
<button v-if="!sidebarOpen" @click="openSidebar"
|
|
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
|
|
<span class="i-mynaui-panel-left-open text-5"></span>
|
|
</button>
|
|
<h4 class="text-lg truncate">
|
|
{{ topic?.name }}
|
|
</h4>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- chat pane -->
|
|
<div ref="chatPaneWrapper"
|
|
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center pt-4">
|
|
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
|
|
<div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
|
|
<Suspense>
|
|
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
|
<Message v-for="message in topic.messages" :key="message.id" :message="message"
|
|
@edit="(value) => patchMessageLocally(message.id, { content: value })"
|
|
@patch="(updates) => patchMessageLocally(message.id, updates)"
|
|
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)"
|
|
@fork="handleFork(message)" />
|
|
</template>
|
|
</Suspense>
|
|
</div>
|
|
|
|
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
|
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
|
:loading="activeGeneration !== null" :allow-manual-role="true" :agent="agent"
|
|
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage"
|
|
@add-message="handleAddMessage" @cancel="handleCancel" @resize="handleResize" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<MessageTokenDetailsDropdown />
|
|
</template>
|