Files
hub/frontend/src/views/Chat.vue
T

592 lines
15 KiB
Vue

<script setup>
import {
ref, watch, computed,
onMounted, onUnmounted,
} from "vue";
import TitleBar from '../components/TitleBar.vue';
import Message from '../components/Message.vue';
import * as backend from '../backend';
import * as ui from '../ui';
import * as runs from '../runs';
const props = defineProps({
id: {
type: String,
required: true,
},
});
let chatId = null;
let agentNames = {};
// Chat State
const chat = ref({});
const messages = ref([]);
const unsentMessages = ref([]);
const currentThreadId = ref(null);
const chatEntries = computed(() => {
return computeChatEntries(messages.value.concat(unsentMessages.value));
});
// UI State
const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int>
const editingMessageId = ref(null);
const noteMode = ref(false);
const messageRunMap = ref({});
const editInfo = computed(() => {
if (editingMessageId.value != null) {
if (noteMode.value) {
return `Editing Note ${editingMessageId.value}`;
} else {
return `Editing ${editingMessageId.value}`;
}
} else if (noteMode.value) {
return 'Editing Note';
} else {
return null;
}
});
let _isMounted = false;
let unsubscribeChat = null;
let unsubscribeRuns = null;
let runListener = null;
// TODO: user sent message is added without id.
// User should receive his own message notification, and find its message to assign it an id.
// Alternatively, return the message id in send-chat-message action. Think thats better.
let _messageNonce = 1;
// === State Management === //
async function loadChatState (newId) {
// This line was converting newId into an integer.
// Some parts of the program may still rely on it.
chatId = newId;
// Create an agent name dictionary
const _agents = await backend.getAgents();
for (let agent of _agents) {
agentNames[agent.id] = agent.name;
}
backend.getChat(chatId).then(info => {
chat.value = Object.assign({}, info);
});
unsubscribeChat = backend.onChatMessages(chatId, it => {
if (it.update) {
// Find message in list. If not found, add it
for (let entry of messages.value) {
if (entry.id == it.id) {
entry.type = it.kind;
entry.subkind = it.subkind;
entry.format = it.format;
entry.content = it.content;
entry.threadId = it.threadId;
return;
}
}
}
if (it.sender == 'user') {
let unsent = unsentMessages.value.shift();
if (unsent) {
if (unsent.content == it.content) {
unsent.id = it.id;
addMessage(unsent);
return;
} else {
// Remove from unsent list and add message as received
ui.alert("TODO: update unsent message");
}
}
}
addMessage({
id: it.id,
type: it.kind,
subkind: it.subkind,
format: it.format,
sender: it.sender,
content: it.content,
threadId: it.threadId,
});
});
unsubscribeRuns = runs.onChatMessage(chatId, onRunMessage);
let response = await backend.sendAction("get-chat-messages", {chatId});
// Always good to duplicate when assigning to a ref
messages.value = response.messages.map(it => ({
//id: it.id,
id: _messageNonce++,
type: it.kind,
subkind: it.subkind,
sender: it.sender,
content: it.content,
threadId: it.threadId,
}));
// Always send messages in a thread, determine which one to start with
if (response.messages.length > 0) {
const lastMessage = response.messages[response.messages.length - 1];
const lastThread = lastMessage.threadId;
if (lastThread == null) {
startNewThread();
} else {
currentThreadId.value = lastMessage.threadId;
expandThread(lastMessage.threadId, true);
}
} else {
startNewThread();
}
// This must happen on the same async-thread that sets `messages`
requestRescroll({forceBottom: true});
// Reset UI State
editingMessageId.value = null;
noteMode.value = false;
// don't reset expandedThreads, it's nice to keep for the session
}
async function unloadChatState () {
if (unsubscribeChat) unsubscribeChat();
unsubscribeChat = null;
if (unsubscribeRuns) unsubscribeRuns();
unsubscribeRuns = null;
}
function onRunMessage (event) {
if (!runListener) {
runListener = runs.addRunListener(onRunUpdate);
}
runListener.addRunId(event.runId);
messageRunMap.value[event.messageId] = event.runId;
}
function onRunUpdate (newRun) {
if (newRun.done) {
runListener.runIds.delete(newRun.id);
let map = messageRunMap.value;
for (let messageId in map) {
if (map[messageId] == newRun.id) {
delete map[messageId];
}
}
}
}
// This won't fire when called, the mounted callback will take care
watch(() => props.id, newId => {
if (_isMounted) {
unloadChatState();
loadChatState(newId);
}
})
onMounted(async () => {
_isMounted = true;
loadChatState(props.id);
});
onUnmounted(async () => {
_isMounted = false;
unloadChatState();
});
// === Chat Functionality === //
const inputFieldElem = ref(null);
const chatContainerElem = ref(null);
function addMessage (message, options={}) {
requestRescroll();
if (options.unsent) {
unsentMessages.value.push(message);
} else {
messages.value.push(message);
}
}
async function sendMessage () {
let elem = inputFieldElem.value;
let content = elem.innerText;
elem.innerText = "";
if (!content.trim()) return; // Don't send empty messages
let threadId = currentThreadId.value;
let messageId = editingMessageId.value;
let isNote = noteMode.value;
if (messageId == null) {
backend.sendAction("send-chat-message", {
chatId, content, threadId,
kind: isNote ? 'note' : null,
}).then((response) => {
if (response['error']) {
print("Error sending chat message: ${response['error']}");
}
});
const newMessage = {
type: isNote ? 'note' : 'message',
sender: 'user',
content,
threadId,
};
addMessage(newMessage, {unsent: true});
} else {
const messageRef = messages.value.find((it) => it.id == messageId);
backend.sendAction("update-chat-message", {
messageId, content,
'kind': isNote ? 'note' : null,
}).then((response) => {
if (response['error']) {
print("Error updating message: ${response['error']}");
}
});
messageRef.type = isNote ? 'note' : 'message';
messageRef.content = content;
}
if (isNote) noteMode.value = false;
}
function computeChatEntries (messages) {
let entries = [];
let threadGroups = {}; // Map<int, message[]>
function senderName (sender) {
let isInt = !isNaN(parseInt(sender));
if (isInt) {
return agentNames[sender] || `<agent-${sender}>`;
} else {
return sender;
}
}
function addThreadHeader (threadId, group) {
entries.push({
type: 'thread-header',
threadId,
messageCount: group.length,
expanded: Boolean(expandedThreads.value[threadId]),
});
}
// First pass: Group messages by thread
for (let message of messages) {
if (message.threadId != null) {
let group = threadGroups[message.threadId] ||
(threadGroups[message.threadId] = []);
group.push(message);
}
}
// Second pass: Build the display list with correct ordering
for (let _msg of messages) {
// New message object with updated name
const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)});
// Non-threaded messages, notes, and resources go directly to the entry list
if (message.threadId == null) {
entries.push(message);
} else {
let threadId = message.threadId;
let group = threadGroups[threadId];
// If the thread is expanded, add messages as they appear
if (expandedThreads.value[threadId]) {
// Add the thread header before the first message
if (message.id == group[0].id) {
addThreadHeader(threadId, group);
}
entries.push(message);
} else {
// Add collapsed thread header in place of the last message
if (message.id == group[group.length-1].id) {
addThreadHeader(threadId, group);
}
}
}
}
// Add current thread mannually if it has no messages
const current = currentThreadId.value;
if (current != null && threadGroups[current] == null) {
addThreadHeader(current, []);
}
return entries;
}
function expandThread (threadId, value) {
expandedThreads.value[threadId] = value;
requestRescroll();
}
async function startNewThread () {
const res = await backend.sendAction("create-chat-thread", {chatId});
console.log("Starting a new thread", res);
if (res.threadId != null) {
currentThreadId.value = res.threadId;
expandThread(res.threadId, true);
}
}
async function addRemoteResource () {
const res = await backend.sendAction('remote-list');
const remotes = res.remotes || [];
if (remotes.length == 0) {
ui.alert("No remotes connected");
return;
}
const selected = await ui.contextMenu(remotes.map(r => ({
value: r.name,
title: r.name + (r.description ? ` — ${r.description}` : ''),
})));
if (!selected) return;
const threadId = currentThreadId.value;
backend.sendAction("send-chat-message", {
chatId, content: selected, threadId,
kind: 'resource', subkind: 'remote',
}).then((response) => {
if (response['error']) {
ui.alert("Error adding remote: " + response['error']);
}
});
// Optimistic entry, matched against the server broadcast by content
addMessage({
type: 'resource',
subkind: 'remote',
sender: 'user',
content: selected,
threadId,
}, {unsent: true});
}
function editMessage (message) {
let elem = inputFieldElem.value;
if (message) {
editingMessageId.value = message.id;
elem.innerText = message.content;
} else {
editingMessageId.value = null;
elem.innerText = "";
}
}
async function deleteChat (chat) {
if (!await ui.confirm(`Delete Chat ${chat.name} with id ${chat.id}`)) return;
const res = await backend.sendAction("delete-chat", {chatId: chat.id});
console.log("Chat Delete Output", res);
}
async function renameChat (chat) {
ui.alert("TODO: Rename Chats");
}
// === Special Chat Functions === //
async function sendPicture () {
// todo
}
async function uploadFile () {
// todo
}
// === UI Helper Functions === //
async function chatEntryContextMenu (message) {
let selected = await ui.contextMenu([
{value: 'pin', title: "Pin message"},
{value: 'edit', title: "Edit message"},
{value: 'start-thread', title: "Start thread from here"},
{value: 'delete', icon: 'fa-trash', title: "Delete message"},
]);
switch (selected) {
case 'pin': {
alert("Pin Message (TODO)");
break;
}
case 'edit': {
editMessage(message);
break;
}
case 'start-thread': {
alert("Start Thread (TODO)");
break;
}
case 'delete': {
if (confirm("Are you sure you want to delete this message?")) {
alert("Delete Message (TODO)");
}
break;
}
}
}
function requestRescroll ({forceBottom=false, bottomRelative=false}={}) {
const elem = chatContainerElem.value;
const scroll = elem.scrollTop;
const max = elem.scrollTopMax;
// Rescroll in 2 frames
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
// Chat was fully scrolled down
if (forceBottom || scroll == max) {
elem.scrollTop = elem.scrollTopMax;
} else if (bottomRelative) {
// TODO: Keep scroll at the same distance from the bottom.
// This will be used when the user scrolls to the top and previous messages are loaded
} else {
// Do nothing, scroll remains in place
}
});
});
}
function inputKeyDown (event) {
if (event.key == "Enter" && event.ctrlKey) {
event.preventDefault();
sendMessage();
}
}
</script>
<template>
<main>
<TitleBar>
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
<div>
<button @click="renameChat(chat)">Rename</button>
<button @click="deleteChat(chat)">Delete</button>
</div>
</TitleBar>
<div class="messages-container" ref="chatContainerElem">
<Message
v-for="msg in chatEntries"
:key="msg.id || msg.threadId"
:message="msg"
@open-menu="chatEntryContextMenu"
@toggle-thread="expandThread"
:cancellable="messageRunMap[msg.id]"
@cancel="runs.cancelRun(messageRunMap[msg.id])"
/>
</div>
<div class="input-container">
<div v-if="editInfo" style="font-size: 75%; margin-bottom: 0.7em;">
{{editInfo}}
</div>
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
<div class="input-row">
<button @click="startNewThread()">
New Thread
</button>
<button @click="addRemoteResource()">
Add Remote
</button>
<button @click="noteMode=!noteMode">
{{noteMode ? 'Cancel Note' : 'Make Note'}}
</button>
<button v-if="editingMessageId != null" @click="editMessage(null)">
Cancel Edit
</button>
<button @click="sendPicture()">
Send Picture
</button>
<button @click="uploadFile()">
Upload File
</button>
<button id="send-button" @click="sendMessage()">
Send
<span style="font-size: 66%;">(Ctrl+Enter)</span>
</button>
</div>
</div>
</main>
</template>
<style scoped>
main {
display: flex;
flex-direction: column;
}
.messages-container {
flex: 1;
overflow-y: scroll;
padding: 20px;
display: flex;
flex-direction: column;
}
.messages-container > div {
margin-bottom: 10px;
}
.input-container {
padding: 10px;
background-color: var(--color-background-soft);
border-top: 1px solid var(--color-border);
}
.input-field {
white-space: preserve;
outline: none;
}
.input-field:empty::before {
opacity: 50%;
content: "Type your message...";
}
.input-row {
width: 100%;
}
#send-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #4caf50;
color: #fff;
cursor: pointer;
height: min-content;
/* align-self: end; /* for flex rows */
float: right; /* for flow rows */
}
</style>