refactor: split chat functionality in chat.js
This commit is contained in:
1 parent
5aa086cb15
commit
dac5d201cd
2 files changed
+449
-399
No files matched your search
@@ -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
@@ -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()">
|
||||||
|
|||||||
Reference in new issue
Block a user