Support creating chats. Add agents in sidebar. Improve chat state management

This commit is contained in:
Shivering Ocelot committed 2025-07-12 19:27:15 -05:00
1 parent 73fe8a95d4
commit f8edd01c37
6 files changed
+184 -49

No files matched your search

+19 -4
View File
@@ -16,7 +16,8 @@ let backendUrl = null;
let nextNonce = 0; let nextNonce = 0;
let reconnectTimeout = null; // For managing the reconnect timer let reconnectTimeout = null; // For managing the reconnect timer
let cache = { let cache = {
chat: null, chats: null,
agents: null,
}; };
// Auth // Auth
@@ -256,10 +257,24 @@ export async function getChat(chatId) {
return allChats.find(chat => chat.id === chatId) || null; return allChats.find(chat => chat.id === chatId) || null;
} }
export function invalidateCache({ chat = false } = {}) { export async function getAgents() {
if (chat) { if (cache.agents === null) {
cache.chats = null; const response = await sendAction("get-agents", {});
if (response && response.agents) {
cache.agents = response.agents.map(msg => ({
id: msg.id,
name: msg.name?.length > 0 ? msg.name : `<agent-${msg.id}>`,
}));
} else {
throw new Error("Invalid response for get-agents: " + JSON.stringify(response));
} }
}
return cache.agents;
}
export function invalidateCache({ chats=false, agents=false } = {}) {
if (chats) cache.chats = null;
if (agents) cache.agents = null;
} }
+12 -6
View File
@@ -14,8 +14,11 @@
const agents = ref([]); const agents = ref([]);
onMounted(async () => { onMounted(async () => {
chats.value = (await backend.getChats()).map(entry => { chats.value = (await backend.getChats()).map(it => {
return {id: entry.id, name: entry.name}; return {id: it.id, name: it.name};
});
agents.value = (await backend.getAgents()).map(it => {
return {id: it.id, name: it.name};
}); });
}); });
</script> </script>
@@ -35,10 +38,13 @@
</li> </li>
<li><i class="fa-solid fa-angles-left"></i></li> <li><i class="fa-solid fa-angles-left"></i></li>
</ul> </ul>
<nav> <nav v-if="mode=='chats'">
<RouterLink v-if="mode=='chats'" v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink> <RouterLink v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink>
<RouterLink to="/chat/create" class="nav-item">Create Chat</RouterLink>
<RouterLink v-if="mode=='agents'" v-for="it in agents" :to="'/agent/' + it.id" class="nav-item">{{it.name}}</RouterLink> </nav>
<nav v-if="mode=='agents'">
<RouterLink v-for="it in agents" :to="'/agent/' + it.id" class="nav-item">{{it.name}}</RouterLink>
<RouterLink to="/agent/create" class="nav-item">Create Agent</RouterLink>
</nav> </nav>
</aside> </aside>
</template> </template>
+5
View File
@@ -28,6 +28,11 @@ const router = createRouter({
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'), component: () => import('../views/AboutView.vue'),
}, },
{
name: 'chat-create',
path: `/chat/create`,
component: () => import('../views/ChatCreate.vue')
},
objectView('chat', () => import('../views/Chat.vue')), objectView('chat', () => import('../views/Chat.vue')),
], ],
}) })
+3 -1
View File
@@ -1,7 +1,9 @@
// Global utility functions related with UI functionality // Global utility functions related with UI functionality
import router from './router'
export function goto (path) { export function goto (path) {
window.location.hash = "#" + path; router.push(path);
} }
export async function confirm (prompt) { export async function confirm (prompt) {
+62 -38
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, computed, reactive, ref, watch,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -24,16 +24,73 @@
const editingMessageId = ref(null); const editingMessageId = ref(null);
const noteMode = ref(false); const noteMode = ref(false);
let _isMounted = false;
let unsubscribeChat = null; let unsubscribeChat = null;
// === State Management === //
async function loadChatState (chatId) {
unsubscribeChat = backend.onChatMessages(chatId, it => {
if (it.sender == 'user') return;
addMessage({
type: it.kind,
sender: it.sender,
content: it.content,
threadId: it.threadId,
});
});
let response = await backend.sendAction("get-chat-messages", {chatId});
// Always good to duplicate when assigning to a ref
messages.value = response['messages'].map(it => ({
type: it.kind,
sender: it.sender,
content: it.content,
threadId: it.threadId,
}));
// This must happen on the same async-thread that sets `messages`
requestRescroll({forceBottom: true});
// Reset UI State
editingMessageId.value = null;
noteMode.value = false;
// no need to reset expandedThreads, it's nice that it will be remembered
}
async function unloadChatState () {
if (unsubscribeChat) unsubscribeChat();
unsubscribeChat = null;
}
// This won't fire when called, the mounted callback will take care
watch(() => props.id, newChatId => {
if (_isMounted) {
unloadChatState();
loadChatState(newChatId);
}
})
onMounted(async () => {
_isMounted = true;
loadChatState(props.id);
});
onUnmounted(async () => {
_isMounted = false;
unloadChatState();
});
// === Chat Functionality === // // === Chat Functionality === //
const inputFieldElem = ref(null); const inputFieldElem = ref(null);
const chatContainerElem = ref(null); const chatContainerElem = ref(null);
async function getChatMessages() { async function getChatMessages(chatId) {
let response = await backend.sendAction("get-chat-messages", {'chatId': props.id}); let response = await backend.sendAction("get-chat-messages", {chatId});
let messageObjects = response['messages']; let messageObjects = response['messages'];
return messageObjects; return messageObjects;
} }
@@ -97,7 +154,7 @@
// === UI Helper Functions === // // === UI Helper Functions === //
function requestRescroll () { function requestRescroll ({forceBottom=false, bottomRelative=false}) {
const elem = chatContainerElem.value; const elem = chatContainerElem.value;
const scroll = elem.scrollTop; const scroll = elem.scrollTop;
@@ -107,7 +164,7 @@
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// Chat was fully scrolled down // Chat was fully scrolled down
if (scroll == max) { if (forceBottom || scroll == max) {
elem.scrollTop = elem.scrollTopMax; elem.scrollTop = elem.scrollTopMax;
} else { } else {
// Do nothing, scroll remains in place // Do nothing, scroll remains in place
@@ -133,39 +190,6 @@
} }
} }
// === 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> </script>
<template> <template>
+83
View File
@@ -0,0 +1,83 @@
<script setup>
import {
ref, computed, reactive,
onMounted, onUnmounted,
} from "vue";
import * as backend from '../backend';
import * as ui from '../ui';
// List of agents, with their id, name, and whether they're currently selected
const agents = ref([
{id: 1, name: "John"},
{id: 2, name: "Steve"},
]);
const title = ref("");
async function submitChat () {
const selectedAgents = agents.value.filter(agent => agent.selected).map(agent => agent.id);
let response = await backend.sendAction("create-chat", {
'name': title.value,
'members': selectedAgents,
});
if (response.error) {
ui.alert(`Could not create chat\n${response.error}`);
} else {
ui.alert(`Created Chat "${title.value}"`);
backend.invalidateCache({chats: true});
ui.goto(`/chat/${response.chatId}`);
}
}
// === Initialize === //
onMounted(async () => {
agents.value = (await backend.getAgents()).map(it => ({
id: it.id,
name: it.name,
selected: false,
}));
// TODO: Get agent list
});
</script>
<template>
<main>
<h1>Create New Chat</h1>
<div class="main-container">
<input v-model="title" type="text" placeholder="title">
<div class="multi-select-box">
<div v-for="agent in agents" :class="{selected: Boolean(agent.selected)}" @click="agent.selected = !agent.selected">{{agent.name}}</div>
</div>
<button @click="submitChat()">Create Chat</button>
</div>
</main>
</template>
<style scoped>
.main-container {
display: flex;
flex-direction: column;
}
.multi-select-box {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
}
.multi-select-box div {
border-bottom: 1px solid var(--color-border);
}
.multi-select-box div:last {
border-bottom: none;
}
.multi-select-box div:hover {
background-color: var(--color-background-mute);
}
.multi-select-box div.selected {
background-color: var(--color-background-soft);
}
</style>