refactor: split chat functionality in chat.js

This commit is contained in:
Shivering Ocelot committed 2026-09-21 13:22:46 -05:00
1 parent 5aa086cb15
commit dac5d201cd
2 files changed
+449 -399

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
import { ref, computed } from 'vue';
import * as backend from './backend';
import * as runs from './runs';
import * as ui from './ui';
// === 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;
}
// === Chat Session === //
// State and logic of one open chat, decoupled from the document.
// The view creates a session, hooks onRescroll() to its message container,
// and drives it with load()/unload(). Shared state is exposed as refs.
export class ChatSession {
constructor () {
// Chat state
this.chat = ref({});
this.messages = ref([]);
this.unsentMessages = ref([]);
this.currentThreadId = ref(null);
this.isNewThread = ref(true); // "New Thread" UI state
this.messageRunMap = ref({}); // messageId -> runId of the run it started
// Editing state
this.editingMessageId = ref(null);
this.noteMode = ref(false);
this.chatEntries = computed(() => {
return this.computeChatEntries(
this.messages.value.concat(this.unsentMessages.value)
);
});
this.editInfo = computed(() => {
const parts = [this.isNewThread.value ? 'New Thread' : `Thread ${this.currentThreadId.value}`];
if (this.editingMessageId.value != null) {
if (this.noteMode.value) {
parts.push(`Editing Note ${this.editingMessageId.value}`);
} else {
parts.push(`Editing ${this.editingMessageId.value}`);
}
} else if (this.noteMode.value) {
parts.push('Editing Note');
}
return parts.join(' - ');
});
// Internals
this.chatId = null;
this.agentNames = {};
// TODO: user sent messages are added without an id; send-chat-message
// should return the message id instead of matching by content
this._messageNonce = 1;
this._unsubscribeChat = null;
this._unsubscribeRuns = null;
this._runListener = null;
this._rescroll = null;
}
// The view injects a hook that re-scrolls its message container
onRescroll (callback) {
this._rescroll = callback;
}
rescroll (options) {
if (this._rescroll) this._rescroll(options);
}
// === Loading === //
async load (chatId) {
this.unload();
this.chatId = chatId;
// Reset state
this.chat.value = {};
this.messages.value = [];
this.unsentMessages.value = [];
this.messageRunMap.value = {};
this.currentThreadId.value = null;
this.isNewThread.value = true;
this.editingMessageId.value = null;
this.noteMode.value = false;
// Create an agent name dictionary
const agents = await backend.getAgents();
if (this.chatId != chatId) return; // A newer load or unload has won
this.agentNames = {};
for (let agent of agents) {
this.agentNames[agent.id] = agent.name;
}
backend.getChat(chatId).then(info => {
if (this.chatId == chatId) {
this.chat.value = Object.assign({}, info);
}
});
this._unsubscribeChat = backend.onChatMessages(chatId, it => this.receiveMessage(it));
this._unsubscribeRuns = runs.onChatMessage(chatId, event => this.trackRun(event));
const response = await backend.sendAction("get-chat-messages", {chatId});
if (this.chatId != chatId) return;
// Always good to duplicate when assigning to a ref
this.messages.value = response.messages.map(it => ({
id: this._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
const lastMessage = response.messages[response.messages.length - 1];
if (lastMessage && lastMessage.threadId != null && !isMessageStale(lastMessage)) {
this.currentThreadId.value = lastMessage.threadId;
this.isNewThread.value = false;
}
// Must happen on the same async-thread that sets `messages`
this.rescroll({forceBottom: true});
}
unload () {
this.chatId = null;
if (this._unsubscribeChat) this._unsubscribeChat();
this._unsubscribeChat = null;
if (this._unsubscribeRuns) this._unsubscribeRuns();
this._unsubscribeRuns = null;
if (this._runListener) this._runListener.cancel();
this._runListener = null;
}
// === Incoming Messages === //
// Handles a chat message broadcast from the backend
receiveMessage (it) {
if (it.update) {
// Find message in list. If not found, add it
for (let entry of this.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 = this.unsentMessages.value.shift();
if (unsent) {
if (unsent.content == it.content) {
unsent.id = it.id;
unsent.threadId = it.threadId;
this.addMessage(unsent);
return;
} else {
// Remove from unsent list and add message as received
ui.alert("TODO: update unsent message");
}
}
}
this.addMessage({
id: it.id,
type: it.kind,
subkind: it.subkind,
format: it.format,
sender: it.sender,
content: it.content,
threadId: it.threadId,
});
}
// === Run Tracking === //
// Tracks the run started by a message, so the view can offer cancellation
trackRun (event) {
if (!this._runListener) {
this._runListener = runs.addRunListener(run => this.onRunUpdate(run));
}
this._runListener.addRunId(event.runId);
this.messageRunMap.value[event.messageId] = event.runId;
}
onRunUpdate (run) {
if (!run.done) return;
// TODO: RunListener should expose a removeRunId() method
this._runListener.runIds.delete(run.id);
const map = this.messageRunMap.value;
for (let messageId in map) {
if (map[messageId] == run.id) {
delete map[messageId];
}
}
}
cancelMessageRun (messageId) {
const runId = this.messageRunMap.value[messageId];
if (runId != null) runs.cancelRun(runId);
}
// === Sending === //
addMessage (message, options={}) {
this.rescroll();
if (options.unsent) {
message.id = this._messageNonce++;
this.unsentMessages.value.push(message);
} else {
this.messages.value.push(message);
}
}
// Sends a new user message, or updates the one being edited.
// `kind`/`subkind` produce special messages (notes, resources, ...).
// Returns false when nothing was sent, so the view can restore its input.
async sendMessage (content, { kind=null, subkind=null } = {}) {
if (!content || !content.trim()) return false;
if (kind == null && this.noteMode.value) kind = 'note';
const messageId = this.editingMessageId.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
const threadId = await this.resolveSendThread();
if (threadId == null) return false;
backend.sendAction("send-chat-message", {
chatId: this.chatId, content, threadId, kind, subkind,
}).then(response => {
if (response && response.error) {
ui.alert("Error sending chat message: " + response.error);
}
});
this.addMessage({
type: kind || 'message',
subkind,
sender: 'user',
content,
threadId,
}, {unsent: true});
} else {
const messageRef = this.messages.value.find(it => it.id == messageId);
backend.sendAction("update-chat-message", {
messageId, content, kind,
}).then(response => {
if (response && response.error) {
ui.alert("Error updating message: " + response.error);
}
});
messageRef.type = kind || 'message';
messageRef.content = content;
this.editingMessageId.value = null;
}
if (kind == 'note') this.noteMode.value = false;
return true;
}
// Returns the threadId to send into, creating the thread upstream
// if we're in "New Thread" state
async resolveSendThread () {
if (!this.isNewThread.value) return this.currentThreadId.value;
const res = await backend.sendAction("create-chat-thread", {chatId: this.chatId});
if (res.threadId == null) {
ui.alert("Error creating thread" + (res.error ? `: ${res.error}` : ""));
return null;
}
this.currentThreadId.value = res.threadId;
this.isNewThread.value = false;
return res.threadId;
}
// Begins or ends message editing; the view keeps its input field in sync
setEditing (messageId) {
this.editingMessageId.value = messageId ?? null;
}
// === Threads === //
activateThread (threadId) {
if (threadId == null) return; // "New Thread" placeholder isn't clickable
// Continuing a thread: it becomes the one open thread
this.currentThreadId.value = threadId;
this.isNewThread.value = false;
this.rescroll();
}
startNewThread () {
// Pure UI state — nothing is created upstream until a message is sent
this.isNewThread.value = true;
this.currentThreadId.value = null;
this.rescroll();
}
// === Entry Computation === //
// Builds the display list: the active thread's messages in order, every
// other thread collapsed into a single preview header
computeChatEntries (messages) {
let entries = [];
let threadGroups = {}; // Map<int, message[]>
const senderName = (sender) => {
let isInt = !isNaN(parseInt(sender));
if (isInt) {
return this.agentNames[sender] || `<agent-${sender}>`;
} else {
return sender;
}
};
const 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 = this.isNewThread.value ? null : this.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 (this.isNewThread.value) {
addThreadHeader(null, []);
}
return entries;
}
}
+39 -399
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, watch, computed, ref, watch,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -9,7 +9,7 @@
import * as backend from '../backend'; import * as backend from '../backend';
import * as ui from '../ui'; import * as ui from '../ui';
import * as runs from '../runs'; import { ChatSession } from '../chat';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -18,224 +18,36 @@
}, },
}); });
let chatId = null; // Chat state and logic live in the session; the view binds to its refs
let agentNames = {}; const session = new ChatSession();
const {
chat, chatEntries, editInfo,
noteMode, editingMessageId, messageRunMap,
} = session;
// Chat State // UI state coupled to the document itself
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 expandedLogs = ref({}); // Message ids of logs currently expanded const expandedLogs = ref({}); // Message ids of logs currently expanded
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 inputFieldElem = ref(null);
const chatContainerElem = ref(null); const chatContainerElem = ref(null);
function addMessage (message, options={}) { let _isMounted = false;
requestRescroll();
if (options.unsent) { watch(() => props.id, newId => {
message.id = _messageNonce++; if (_isMounted) session.load(newId);
unsentMessages.value.push(message); });
} else {
messages.value.push(message); onMounted(() => {
} _isMounted = true;
} session.onRescroll(requestRescroll); // function declarations are hoisted
session.load(props.id);
});
onUnmounted(() => {
_isMounted = false;
session.unload();
});
// === Chat Functionality === //
async function sendMessage () { async function sendMessage () {
let elem = inputFieldElem.value; let elem = inputFieldElem.value;
@@ -243,159 +55,9 @@
let content = elem.innerText; let content = elem.innerText;
elem.innerText = ""; elem.innerText = "";
if (!content.trim()) return; // Don't send empty messages // False when nothing was sent (empty input or thread creation failed)
const ok = await session.sendMessage(content);
let messageId = editingMessageId.value; if (!ok) elem.innerText = content; // let the user retry
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 () { async function addRemoteResource () {
@@ -412,36 +74,14 @@
}))); })));
if (!selected) return; if (!selected) return;
const threadId = await resolveSendThread(); // Sends a 'resource' message into the current (or a new) thread
if (threadId == null) return; await session.sendMessage(selected, {kind: 'resource', subkind: 'remote'});
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) { function editMessage (message) {
let elem = inputFieldElem.value; let elem = inputFieldElem.value;
if (message) { elem.innerText = message ? message.content : "";
editingMessageId.value = message.id; session.setEditing(message ? message.id : null);
elem.innerText = message.content;
} else {
editingMessageId.value = null;
elem.innerText = "";
}
} }
async function deleteChat (chat) { async function deleteChat (chat) {
@@ -520,14 +160,14 @@
const elem = chatContainerElem.value; const elem = chatContainerElem.value;
const scroll = elem.scrollTop; const scroll = elem.scrollTop;
const max = elem.scrollTopMax; const max = elem.scrollHeight - elem.clientHeight;
// Rescroll in 2 frames // Rescroll in 2 frames
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// Chat was fully scrolled down // Chat was fully scrolled down
if (forceBottom || scroll == max) { if (forceBottom || scroll == max) {
elem.scrollTop = elem.scrollTopMax; elem.scrollTop = max;
} else if (bottomRelative) { } else if (bottomRelative) {
// TODO: Keep scroll at the same distance from the bottom. // 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 // This will be used when the user scrolls to the top and previous messages are loaded
@@ -563,13 +203,13 @@
:key="msg.id ?? ('thread-' + msg.threadId)" :key="msg.id ?? ('thread-' + msg.threadId)"
:message="msg" :message="msg"
@open-menu="chatEntryContextMenu" @open-menu="chatEntryContextMenu"
@open-thread="activateThread" @open-thread="session.activateThread"
:log-expanded="expandedLogs[msg.id]" :log-expanded="expandedLogs[msg.id]"
@toggle-log="toggleLogExpand(msg)" @toggle-log="toggleLogExpand(msg)"
:cancellable="messageRunMap[msg.id]" :cancellable="messageRunMap[msg.id]"
@cancel="runs.cancelRun(messageRunMap[msg.id])" @cancel="session.cancelMessageRun(msg.id)"
/> />
</div> </div>
@@ -581,7 +221,7 @@
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div> <div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
<div class="input-row"> <div class="input-row">
<button @click="startNewThread()"> <button @click="session.startNewThread()">
New Thread New Thread
</button> </button>
<button @click="addRemoteResource()"> <button @click="addRemoteResource()">