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 reconnectTimeout = null; // For managing the reconnect timer
let cache = {
chat: null,
chats: null,
agents: null,
};
// Auth
@@ -256,10 +257,24 @@ export async function getChat(chatId) {
return allChats.find(chat => chat.id === chatId) || null;
}
export function invalidateCache({ chat = false } = {}) {
if (chat) {
cache.chats = null;
export async function getAgents() {
if (cache.agents === 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([]);
onMounted(async () => {
chats.value = (await backend.getChats()).map(entry => {
return {id: entry.id, name: entry.name};
chats.value = (await backend.getChats()).map(it => {
return {id: it.id, name: it.name};
});
agents.value = (await backend.getAgents()).map(it => {
return {id: it.id, name: it.name};
});
});
</script>
@@ -35,10 +38,13 @@
</li>
<li><i class="fa-solid fa-angles-left"></i></li>
</ul>
<nav>
<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 v-if="mode=='chats'">
<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>
</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>
</aside>
</template>
+5
View File
@@ -28,6 +28,11 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
{
name: 'chat-create',
path: `/chat/create`,
component: () => import('../views/ChatCreate.vue')
},
objectView('chat', () => import('../views/Chat.vue')),
],
})
+3 -1
View File
@@ -1,7 +1,9 @@
// Global utility functions related with UI functionality
import router from './router'
export function goto (path) {
window.location.hash = "#" + path;
router.push(path);
}
export async function confirm (prompt) {
+62 -38
View File
@@ -1,6 +1,6 @@
<script setup>
import {
ref, computed, reactive,
ref, watch,
onMounted, onUnmounted,
} from "vue";
@@ -24,16 +24,73 @@
const editingMessageId = ref(null);
const noteMode = ref(false);
let _isMounted = false;
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 === //
const inputFieldElem = ref(null);
const chatContainerElem = ref(null);
async function getChatMessages() {
let response = await backend.sendAction("get-chat-messages", {'chatId': props.id});
async function getChatMessages(chatId) {
let response = await backend.sendAction("get-chat-messages", {chatId});
let messageObjects = response['messages'];
return messageObjects;
}
@@ -97,7 +154,7 @@
// === UI Helper Functions === //
function requestRescroll () {
function requestRescroll ({forceBottom=false, bottomRelative=false}) {
const elem = chatContainerElem.value;
const scroll = elem.scrollTop;
@@ -107,7 +164,7 @@
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
// Chat was fully scrolled down
if (scroll == max) {
if (forceBottom || scroll == max) {
elem.scrollTop = elem.scrollTopMax;
} else {
// 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>
<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>