Chat View and Sidebar chat list
This commit is contained in:
1 parent
5b59d3df75
commit
73fe8a95d4
7 files changed
+350
-39
No files matched your search
@@ -23,15 +23,15 @@
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
@@ -40,22 +40,23 @@
|
||||
:root {
|
||||
--color-green: #00bd7e;
|
||||
--color-green-dark: #003825;
|
||||
--color-blue: #3d8de1;
|
||||
|
||||
--border-radius: 6px;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
*[hidden] { display: none !important; }
|
||||
|
||||
a,
|
||||
.green {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--color-green);
|
||||
color: var(--color-blue);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
+13
-13
@@ -25,8 +25,8 @@ let loginTokenDeferred = createDeferred();
|
||||
let authDeferred = createDeferred();
|
||||
|
||||
// Handlers
|
||||
const pendingActions = new Map(); // Maps nonce to { resolve, reject }
|
||||
const chatListeners = new Map(); // Maps chatId to an array of callback functions
|
||||
const pendingActions = {}; // Maps nonce to { resolve, reject }
|
||||
const chatListeners = {}; // Maps chatId to an array of callback functions
|
||||
const orphanHandlers = []; // Queue of callbacks for orphan server messages
|
||||
|
||||
function createDeferred() {
|
||||
@@ -120,9 +120,9 @@ export function sendAction(action, body = {}) {
|
||||
const nonce = nextNonce++;
|
||||
const deferred = createDeferred();
|
||||
|
||||
pendingActions.set(nonce, deferred);
|
||||
pendingActions[nonce] = deferred;
|
||||
deferred.promise.finally(() => {
|
||||
pendingActions.delete(nonce); // Clean up
|
||||
delete pendingActions[nonce]; // Clean up
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
@@ -156,7 +156,7 @@ function handleIncomingMessage(rawData) {
|
||||
|
||||
if (packet.nonce != null) {
|
||||
const nonce = packet.nonce;
|
||||
const actionPromise = pendingActions.get(nonce);
|
||||
const actionPromise = pendingActions[nonce];
|
||||
if (actionPromise) {
|
||||
if (packet.error) {
|
||||
actionPromise.reject(new Error(packet.error));
|
||||
@@ -168,7 +168,7 @@ function handleIncomingMessage(rawData) {
|
||||
}
|
||||
} else if (packet.action === 'chat-message') {
|
||||
const chatId = packet.chatId;
|
||||
const listeners = chatListeners.get(chatId);
|
||||
const listeners = chatListeners[chatId];
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => {
|
||||
try {
|
||||
@@ -213,15 +213,15 @@ async function postRequest(path, body) {
|
||||
// --- Data Management ---
|
||||
|
||||
// Returns a function to unsubscribe
|
||||
export function chatMessages(chatId, callback) {
|
||||
export function onChatMessages(chatId, callback) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw new TypeError("chatMessages: callback must be a function.");
|
||||
}
|
||||
|
||||
if (!chatListeners.has(chatId)) {
|
||||
chatListeners.set(chatId, []);
|
||||
if (!chatListeners[chatId]) {
|
||||
chatListeners[chatId] = [];
|
||||
}
|
||||
const listeners = chatListeners.get(chatId);
|
||||
const listeners = chatListeners[chatId];
|
||||
listeners.push(callback);
|
||||
|
||||
// Return an unsubscribe function
|
||||
@@ -231,7 +231,7 @@ export function chatMessages(chatId, callback) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
if (listeners.length === 0) {
|
||||
chatListeners.delete(chatId);
|
||||
delete chatListeners[chatId];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -239,8 +239,8 @@ export function chatMessages(chatId, callback) {
|
||||
export async function getChats() {
|
||||
if (cache.chats === null) {
|
||||
const response = await sendAction("get-chats", {});
|
||||
if (response && response.cache.chats) {
|
||||
cache.chats = response.cache.chats.map(msg => new ChatInfo({
|
||||
if (response && response.chats) {
|
||||
cache.chats = response.chats.map(msg => new ChatInfo({
|
||||
id: msg.id,
|
||||
name: msg.name?.length > 0 ? msg.name : `[Chat ${msg.id}]`,
|
||||
}));
|
||||
|
||||
@@ -5,24 +5,40 @@
|
||||
onMounted, onUnmounted,
|
||||
} from "vue";
|
||||
|
||||
//import * as horde from '../../horde';
|
||||
import * as backend from '../../backend';
|
||||
import * as ui from '../../ui';
|
||||
|
||||
const collapsed = ref(false);
|
||||
const mode = ref('chats');
|
||||
const chats = ref([]);
|
||||
const agents = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
chats.value = (await backend.getChats()).map(entry => {
|
||||
return {id: entry.id, name: entry.name};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="nav-button expand-navbar" v-if="collapsed" @click="collapsed = false;">
|
||||
<button class="nav-button expand-navbar" v-if="collapsed" @click="collapsed=false;">
|
||||
</button>
|
||||
|
||||
<aside class="sidebar" style="min-width:8em" v-else>
|
||||
<div class="drag-bar"></div>
|
||||
<ul class="tab-bar">
|
||||
<li><i class="fa-solid fa-message"></i></li>
|
||||
<li><i class="fa-solid fa-user-group"></i></li>
|
||||
<li @click="mode='chats'" :class="{active: mode=='chats'}">
|
||||
<i class="fa-solid fa-message"></i>
|
||||
</li>
|
||||
<li @click="mode='agents'" :class="{active: mode=='agents'}">
|
||||
<i class="fa-solid fa-user-group"></i>
|
||||
</li>
|
||||
<li><i class="fa-solid fa-angles-left"></i></li>
|
||||
</ul>
|
||||
<nav>
|
||||
<RouterLink to="/" class="nav-item">Home</RouterLink>
|
||||
<RouterLink to="/about" class="nav-item">About</RouterLink>
|
||||
<RouterLink v-if="mode=='chats'" v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink>
|
||||
|
||||
<RouterLink v-if="mode=='agents'" v-for="it in agents" :to="'/agent/' + it.id" class="nav-item">{{it.name}}</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -47,20 +63,24 @@
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
list-style-type: none;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
border-radius: var(--border-radius);
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
transition: background-color 0.5s;
|
||||
}
|
||||
|
||||
.tab-bar li:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
|
||||
.tab-bar li.active {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
|
||||
color: var(--color-green);
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
function objectView (name, mod) {
|
||||
return {
|
||||
name,
|
||||
path: `/${name}/:id`,
|
||||
props: true,
|
||||
component: mod
|
||||
}
|
||||
}
|
||||
|
||||
// These routers must be handled by the server by rendering the index page
|
||||
// file: backend/server.py
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
@@ -17,6 +28,7 @@ const router = createRouter({
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
objectView('chat', () => import('../views/Chat.vue')),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref, computed, reactive,
|
||||
onMounted, onUnmounted,
|
||||
} from "vue";
|
||||
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Chat State
|
||||
const chat = ref({});
|
||||
const messages = ref([]);
|
||||
const currentThreadId = ref(null);
|
||||
|
||||
// UI State
|
||||
const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int>
|
||||
const editingMessageId = ref(null);
|
||||
const noteMode = ref(false);
|
||||
|
||||
let unsubscribeChat = null;
|
||||
|
||||
|
||||
// === Chat Functionality === //
|
||||
|
||||
const inputFieldElem = ref(null);
|
||||
const chatContainerElem = ref(null);
|
||||
|
||||
async function getChatMessages() {
|
||||
let response = await backend.sendAction("get-chat-messages", {'chatId': props.id});
|
||||
let messageObjects = response['messages'];
|
||||
return messageObjects;
|
||||
}
|
||||
|
||||
function addMessage (message) {
|
||||
requestRescroll();
|
||||
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': props.id,
|
||||
'content': content,
|
||||
'threadId': 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);
|
||||
} else {
|
||||
const messageRef = messages.firstWhere((element) => element.id == messageId);
|
||||
|
||||
backend.sendAction("update-chat-message", {
|
||||
'messageId': messageId,
|
||||
'content': 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;
|
||||
}
|
||||
|
||||
|
||||
// === UI Helper Functions === //
|
||||
|
||||
function requestRescroll () {
|
||||
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 (scroll == max) {
|
||||
elem.scrollTop = elem.scrollTopMax;
|
||||
} else {
|
||||
// Do nothing, scroll remains in place
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Something different has to be done when previous messages are added
|
||||
}
|
||||
|
||||
function msgClass (msg) {
|
||||
if (msg.sender == 'user') {
|
||||
return ['user']
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function inputKeyDown (event) {
|
||||
if (event.key == "Enter" && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// === Initialize === //
|
||||
|
||||
onMounted(async () => {
|
||||
requestRescroll();
|
||||
|
||||
messages.value = (await getChatMessages()).map(it => {
|
||||
return {
|
||||
type: it.kind,
|
||||
sender: it.sender,
|
||||
content: it.content,
|
||||
threadId: it.threadId,
|
||||
};
|
||||
});
|
||||
|
||||
if (unsubscribeChat) unsubscribeChat();
|
||||
unsubscribeChat = backend.onChatMessages(props.id, it => {
|
||||
if (it.sender == 'user') return;
|
||||
|
||||
addMessage({
|
||||
type: it.kind,
|
||||
sender: it.sender,
|
||||
content: it.content,
|
||||
threadId: it.threadId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (unsubscribeChat) {
|
||||
unsubscribeChat();
|
||||
unsubscribeChat = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
|
||||
|
||||
<div class="messages-container" ref="chatContainerElem">
|
||||
<div v-for="msg in messages" class="message" :class="msgClass(msg)">
|
||||
<div v-if="msg.type == 'message'" class="sender">{{msg.sender}}</div>
|
||||
|
||||
<!--message v-if="!msg.collapse" :value="msg.content" :format="msg.format"></message-->
|
||||
<div v-if="!msg.collapse" class="content">{{msg.content}}</div>
|
||||
|
||||
<div v-if="msg.collapse">
|
||||
<a href="#" @click.prevent="msg.collapse = false">Show</a>
|
||||
</div>
|
||||
<div v-if="msg.collapse === false">
|
||||
<a href="#" @click.prevent="msg.collapse = true">Hide</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
|
||||
|
||||
<div class="input-row">
|
||||
<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;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.message .content {
|
||||
white-space: preserve
|
||||
}
|
||||
|
||||
.message .sender {
|
||||
font-weight: bold;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.message.user .content {
|
||||
right: 100%;
|
||||
width: auto;
|
||||
}
|
||||
.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>
|
||||
Reference in new issue
Block a user