diff --git a/frontend/src/chat.js b/frontend/src/chat.js new file mode 100644 index 0000000..9912d88 --- /dev/null +++ b/frontend/src/chat.js @@ -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 + + const senderName = (sender) => { + let isInt = !isNaN(parseInt(sender)); + if (isInt) { + return this.agentNames[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; + } +} \ No newline at end of file diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index ee35898..17421b5 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -1,6 +1,6 @@