644 lines
17 KiB
Vue
644 lines
17 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 isNewThread = ref(true); // "New Thread" UI state
|
|
const editingMessageId = ref(null);
|
|
const noteMode = ref(false);
|
|
const messageRunMap = ref({});
|
|
|
|
const editInfo = computed(() => {
|
|
const parts = [isNewThread.value ? 'New Thread' : `Thread ${currentThreadId.value}`]
|
|
if (editingMessageId.value != null) {
|
|
if (noteMode.value) {
|
|
parts.push(`Editing Note ${editingMessageId.value}`);
|
|
} else {
|
|
parts.push(`Editing ${editingMessageId.value}`);
|
|
}
|
|
} else if (noteMode.value) {
|
|
parts.push('Editing Note');
|
|
}
|
|
|
|
return parts.join(' - ');
|
|
});
|
|
|
|
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;
|
|
|
|
|
|
// === Thread Helpers === //
|
|
|
|
const THREAD_STALE_MS = 10 * 60 * 1000;
|
|
|
|
function messageTime (message) {
|
|
// Accept the common backend timestamp field names and formats
|
|
const t = message.timestamp ?? message.time ?? message.createdAt;
|
|
if (t == null) return null;
|
|
const ms = typeof t == 'number' ? (t < 1e12 ? t * 1000 : t) : Date.parse(t);
|
|
return isNaN(ms) ? null : ms;
|
|
}
|
|
|
|
function isMessageStale (message) {
|
|
const time = messageTime(message);
|
|
// No timestamp available -> treat as stale; a new thread is the safer default
|
|
return time == null || (Date.now() - time) > THREAD_STALE_MS;
|
|
}
|
|
|
|
|
|
// === 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;
|
|
unsent.threadId = it.threadId;
|
|
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,
|
|
}));
|
|
|
|
// Continue the last thread only if it is recent (under 10 minutes old);
|
|
// otherwise start in "New Thread" state
|
|
if (response.messages.length > 0) {
|
|
const lastMessage = response.messages[response.messages.length - 1];
|
|
if (lastMessage.threadId != null && !isMessageStale(lastMessage)) {
|
|
activateThread(lastMessage.threadId);
|
|
} else {
|
|
startNewThread();
|
|
}
|
|
} 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;
|
|
// thread state was set above; no need to reset
|
|
}
|
|
|
|
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) {
|
|
message.id = _messageNonce++;
|
|
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 messageId = editingMessageId.value;
|
|
let isNote = noteMode.value;
|
|
|
|
if (messageId == null) {
|
|
// "New Thread" is a UI state: the thread is only created upstream
|
|
// here, once there is a message to put into it
|
|
let threadId = await resolveSendThread();
|
|
if (threadId == null) {
|
|
elem.innerText = content; // let the user retry
|
|
return;
|
|
}
|
|
|
|
backend.sendAction("send-chat-message", {
|
|
chatId, content, threadId,
|
|
kind: isNote ? 'note' : null,
|
|
}).then((response) => {
|
|
if (response['error']) {
|
|
ui.alert("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']) {
|
|
ui.alert("Error updating message: " + response['error']);
|
|
}
|
|
});
|
|
|
|
messageRef.type = isNote ? 'note' : 'message';
|
|
messageRef.content = content;
|
|
}
|
|
|
|
if (isNote) noteMode.value = false;
|
|
}
|
|
|
|
async function resolveSendThread () {
|
|
// Returns the threadId to send into, creating the thread upstream
|
|
// if we're in "New Thread" state
|
|
if (!isNewThread.value) return currentThreadId.value;
|
|
|
|
const res = await backend.sendAction("create-chat-thread", {chatId});
|
|
if (res.threadId == null) {
|
|
ui.alert("Error creating thread" + (res.error ? `: ${res.error}` : ""));
|
|
return null;
|
|
}
|
|
currentThreadId.value = res.threadId;
|
|
isNewThread.value = false;
|
|
return res.threadId;
|
|
}
|
|
|
|
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, options={}) {
|
|
let preview = null;
|
|
const last = group[group.length - 1];
|
|
if (last && !options.active) {
|
|
let content = String(last.content || '').split('\n')[0].trim();
|
|
if (content.length > 100) content = content.slice(0, 100) + '…';
|
|
preview = {sender: senderName(last.sender), content};
|
|
}
|
|
entries.push({
|
|
type: 'thread-header',
|
|
threadId,
|
|
active: !!options.active,
|
|
messageCount: group.length,
|
|
preview,
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Only the active thread is ever open; every other thread collapses
|
|
// into a single preview header
|
|
const activeThreadId = isNewThread.value ? null : currentThreadId.value;
|
|
let activeHeaderAdded = false;
|
|
|
|
// Second pass: Build the display list with correct ordering
|
|
for (let _msg of messages) {
|
|
const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)});
|
|
|
|
if (message.threadId == null) {
|
|
entries.push(message);
|
|
continue;
|
|
}
|
|
|
|
let threadId = message.threadId;
|
|
let group = threadGroups[threadId];
|
|
|
|
if (threadId === activeThreadId) {
|
|
if (!activeHeaderAdded) {
|
|
addThreadHeader(threadId, group, {active: true});
|
|
activeHeaderAdded = true;
|
|
}
|
|
entries.push(message);
|
|
} else if (message.id == group[group.length - 1].id) {
|
|
// Collapsed: one preview card in place of the thread's last message
|
|
addThreadHeader(threadId, group);
|
|
}
|
|
}
|
|
|
|
// "New Thread" UI state: placeholder where the new thread will start
|
|
if (isNewThread.value) {
|
|
addThreadHeader(null, []);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function activateThread (threadId) {
|
|
if (threadId == null) return; // "New Thread" placeholder isn't clickable
|
|
// Continuing a thread: it becomes the one open thread
|
|
currentThreadId.value = threadId;
|
|
isNewThread.value = false;
|
|
requestRescroll();
|
|
}
|
|
|
|
function startNewThread () {
|
|
// Pure UI state — nothing is created upstream until a message is sent
|
|
isNewThread.value = true;
|
|
currentThreadId.value = null;
|
|
requestRescroll();
|
|
}
|
|
|
|
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 = await resolveSendThread();
|
|
if (threadId == null) return;
|
|
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 ?? ('thread-' + msg.threadId)"
|
|
:message="msg"
|
|
@open-menu="chatEntryContextMenu"
|
|
@open-thread="activateThread"
|
|
|
|
: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>
|