initial idea
This commit is contained in:
commit
536b308096
5 files changed
+1092
No files matched your search
+643
@@ -0,0 +1,643 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref, watch, computed,
|
||||
onMounted, onUnmounted,
|
||||
} from "vue";
|
||||
|
||||
import TitleBar from '../components/TitleBar.vue';
|
||||
import Message from '../components/Message.vue';
|
||||
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
import * as runs from '../runs';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
let chatId = null;
|
||||
let agentNames = {};
|
||||
|
||||
// Chat State
|
||||
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 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 chatContainerElem = ref(null);
|
||||
|
||||
function addMessage (message, options={}) {
|
||||
requestRescroll();
|
||||
if (options.unsent) {
|
||||
message.id = _messageNonce++;
|
||||
unsentMessages.value.push(message);
|
||||
} else {
|
||||
messages.value.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage () {
|
||||
let elem = inputFieldElem.value;
|
||||
|
||||
let content = elem.innerText;
|
||||
elem.innerText = "";
|
||||
|
||||
if (!content.trim()) return; // Don't send empty messages
|
||||
|
||||
let messageId = editingMessageId.value;
|
||||
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 () {
|
||||
const res = await backend.sendAction('remote-list');
|
||||
const remotes = res.remotes || [];
|
||||
if (remotes.length == 0) {
|
||||
ui.alert("No remotes connected");
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = await ui.contextMenu(remotes.map(r => ({
|
||||
value: r.name,
|
||||
title: r.name + (r.description ? ` — ${r.description}` : ''),
|
||||
})));
|
||||
if (!selected) return;
|
||||
|
||||
const threadId = await resolveSendThread();
|
||||
if (threadId == null) return;
|
||||
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) {
|
||||
let elem = inputFieldElem.value;
|
||||
if (message) {
|
||||
editingMessageId.value = message.id;
|
||||
elem.innerText = message.content;
|
||||
} else {
|
||||
editingMessageId.value = null;
|
||||
elem.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat (chat) {
|
||||
if (!await ui.confirm(`Delete Chat ${chat.name} with id ${chat.id}`)) return;
|
||||
const res = await backend.sendAction("delete-chat", {chatId: chat.id});
|
||||
console.log("Chat Delete Output", res);
|
||||
}
|
||||
|
||||
async function renameChat (chat) {
|
||||
ui.alert("TODO: Rename Chats");
|
||||
}
|
||||
|
||||
|
||||
// === Special Chat Functions === //
|
||||
|
||||
async function sendPicture () {
|
||||
// todo
|
||||
}
|
||||
|
||||
async function uploadFile () {
|
||||
// todo
|
||||
}
|
||||
|
||||
|
||||
|
||||
// === UI Helper Functions === //
|
||||
|
||||
async function chatEntryContextMenu (message) {
|
||||
let selected = await ui.contextMenu([
|
||||
{value: 'pin', title: "Pin message"},
|
||||
{value: 'edit', title: "Edit message"},
|
||||
{value: 'start-thread', title: "Start thread from here"},
|
||||
{value: 'delete', icon: 'fa-trash', title: "Delete message"},
|
||||
]);
|
||||
|
||||
switch (selected) {
|
||||
case 'pin': {
|
||||
alert("Pin Message (TODO)");
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
editMessage(message);
|
||||
break;
|
||||
}
|
||||
case 'start-thread': {
|
||||
alert("Start Thread (TODO)");
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
if (confirm("Are you sure you want to delete this message?")) {
|
||||
alert("Delete Message (TODO)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function requestRescroll ({forceBottom=false, bottomRelative=false}={}) {
|
||||
const elem = chatContainerElem.value;
|
||||
|
||||
const scroll = elem.scrollTop;
|
||||
const max = elem.scrollTopMax;
|
||||
|
||||
// Rescroll in 2 frames
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
// Chat was fully scrolled down
|
||||
if (forceBottom || scroll == max) {
|
||||
elem.scrollTop = elem.scrollTopMax;
|
||||
} else if (bottomRelative) {
|
||||
// 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
|
||||
} else {
|
||||
// Do nothing, scroll remains in place
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function inputKeyDown (event) {
|
||||
if (event.key == "Enter" && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TitleBar>
|
||||
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
|
||||
<div>
|
||||
<button @click="renameChat(chat)">Rename</button>
|
||||
<button @click="deleteChat(chat)">Delete</button>
|
||||
</div>
|
||||
</TitleBar>
|
||||
|
||||
<div class="messages-container" ref="chatContainerElem">
|
||||
<Message
|
||||
v-for="msg in chatEntries"
|
||||
:key="msg.id ?? ('thread-' + msg.threadId)"
|
||||
:message="msg"
|
||||
@open-menu="chatEntryContextMenu"
|
||||
@open-thread="activateThread"
|
||||
|
||||
:cancellable="messageRunMap[msg.id]"
|
||||
@cancel="runs.cancelRun(messageRunMap[msg.id])"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<div v-if="editInfo" style="font-size: 75%; margin-bottom: 0.7em;">
|
||||
{{editInfo}}
|
||||
</div>
|
||||
|
||||
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
|
||||
|
||||
<div class="input-row">
|
||||
<button @click="startNewThread()">
|
||||
New Thread
|
||||
</button>
|
||||
<button @click="addRemoteResource()">
|
||||
Add Remote
|
||||
</button>
|
||||
<button @click="noteMode=!noteMode">
|
||||
{{noteMode ? 'Cancel Note' : 'Make Note'}}
|
||||
</button>
|
||||
<button v-if="editingMessageId != null" @click="editMessage(null)">
|
||||
Cancel Edit
|
||||
</button>
|
||||
<button @click="sendPicture()">
|
||||
Send Picture
|
||||
</button>
|
||||
<button @click="uploadFile()">
|
||||
Upload File
|
||||
</button>
|
||||
<button id="send-button" @click="sendMessage()">
|
||||
Send
|
||||
<span style="font-size: 66%;">(Ctrl+Enter)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 20px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container > div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 10px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
white-space: preserve;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field:empty::before {
|
||||
opacity: 50%;
|
||||
content: "Type your message...";
|
||||
}
|
||||
|
||||
.input-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#send-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #4caf50;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
height: min-content;
|
||||
|
||||
/* align-self: end; /* for flex rows */
|
||||
float: right; /* for flow rows */
|
||||
}
|
||||
</style>
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket).
|
||||
// Modeled on the browser client in reference/backend.js: nonce-tracked actions,
|
||||
// action listeners, orphan handlers for the auth response, auto-reconnect.
|
||||
|
||||
export class Horde {
|
||||
constructor({ host, token = null }) {
|
||||
this.host = host;
|
||||
this.token = token;
|
||||
this.socket = null;
|
||||
this.nextNonce = 0;
|
||||
this.pending = new Map(); // nonce -> { resolve, reject }
|
||||
this.actionListeners = new Map(); // action -> [callback(packet)]
|
||||
this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies)
|
||||
this.connectListeners = [];
|
||||
this.reconnectTimeout = null;
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.stopped) return;
|
||||
if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return;
|
||||
|
||||
const socket = new WebSocket(this.host + "/ws");
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = async () => {
|
||||
console.log(`Connected to ${this.host}`);
|
||||
try {
|
||||
if (this.token) {
|
||||
socket.send(JSON.stringify({ token: this.token }));
|
||||
const response = await this.waitOrphan();
|
||||
if (response && response.error) {
|
||||
console.error('Hub authentication failed:', response.error);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.warn('No auth token configured; the hub may reject this session');
|
||||
}
|
||||
for (const cb of this.connectListeners) {
|
||||
try { cb(); } catch (err) { console.error('Connect listener failed:', err); }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error during hub login:', err);
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') this.handleMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this.clearPending();
|
||||
if (!this.stopped) {
|
||||
console.log('Disconnected from hub. Reconnecting in 1s...');
|
||||
this.reconnectTimeout = setTimeout(() => this.connect(), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (event) => {
|
||||
console.error('WebSocket error:', event.message || event.error || event);
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
||||
if (this.socket) {
|
||||
this.socket.onclose = null;
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
clearPending() {
|
||||
for (const { reject } of this.pending.values()) {
|
||||
reject(new Error('Connection lost'));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
onConnect(cb) {
|
||||
this.connectListeners.push(cb);
|
||||
}
|
||||
|
||||
onAction(action, cb) {
|
||||
let list = this.actionListeners.get(action);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.actionListeners.set(action, list);
|
||||
}
|
||||
list.push(cb);
|
||||
}
|
||||
|
||||
waitOrphan() {
|
||||
return new Promise((resolve) => this.orphanHandlers.push(resolve));
|
||||
}
|
||||
|
||||
send(value) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket is not open');
|
||||
}
|
||||
this.socket.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
sendAction(action, body = {}) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
const nonce = this.nextNonce++;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.pending.set(nonce, { resolve, reject });
|
||||
});
|
||||
promise.catch(() => {}); // avoid unhandled rejections when nobody awaits
|
||||
this.send({ action, nonce, body });
|
||||
return promise;
|
||||
}
|
||||
|
||||
handleMessage(raw) {
|
||||
let packet;
|
||||
try {
|
||||
packet = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse hub message:', raw, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.nonce != null) {
|
||||
const pending = this.pending.get(packet.nonce);
|
||||
if (!pending) {
|
||||
console.warn(`Orphan response for nonce ${packet.nonce}`);
|
||||
return;
|
||||
}
|
||||
this.pending.delete(packet.nonce);
|
||||
if (packet.error) pending.reject(new Error(packet.error));
|
||||
else pending.resolve(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.action) {
|
||||
const listeners = this.actionListeners.get(packet.action);
|
||||
if (listeners && listeners.length) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(packet); } catch (err) {
|
||||
console.error(`Listener for '${packet.action}' failed:`, err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.orphanHandlers.length) {
|
||||
this.orphanHandlers.shift()(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.error) console.error('Hub error:', packet.error);
|
||||
else console.warn('Ignored hub message:', raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Remotes
|
||||
|
||||
## Overview
|
||||
|
||||
A **remote** is a collection of functions exposed to an AI agent through the WebSocket session. Remotes are scoped to a user and live in memory during the server's lifetime.
|
||||
|
||||
## Architecture & Function Lifecycle
|
||||
|
||||
Remote functions are not standard Python methods attached to the agent. Instead, they are definitions (schemas) wrapped into adapters that fit the expert agent's tool loop.
|
||||
|
||||
1. **Definition (`backend/src/remote.py`)**:
|
||||
* A **`RemoteFunction`** represents a function exposed by a remote. It contains the function's name, description, and its OpenAI-compatible schema for input parameters.
|
||||
* A **`RemoteToolSpec`** wraps a `RemoteFunction` to make it compatible with the agent's tool system. It exposes the required interface: `.name`, `.description`, `.to_openai_dict()` (for the LLM), and `.execute_async(**args)` (for execution).
|
||||
* A **`RemoteConnection`** manages the connection state. It holds the function definitions and an internal queue of pending calls.
|
||||
|
||||
2. **Injection**:
|
||||
* When a run starts (see **Agent Integration** below), the expert agent requests tools for specific remotes. The server resolves these remote names into `RemoteToolSpec` objects and adds them to the run's input tools list.
|
||||
|
||||
3. **Execution**:
|
||||
* When the AI decides to use a remote tool, the expert agent's loop calls `execute_async` on the `RemoteToolSpec`.
|
||||
* This triggers `RemoteConnection.call()`, which sends a request over the WebSocket to the session that owns the remote.
|
||||
* The remote executes the function code (for local remotes) or accepts an RPC call (for external remotes).
|
||||
* Results are sent back through `remote-call-result` actions. Pending calls time out after 30 seconds if no response is received.
|
||||
|
||||
4. **Lifecycle**:
|
||||
* Remotes are established when a user connects them via the UI (`remote-connect`).
|
||||
* They persist for the session lifetime unless manually disconnected (`remote-disconnect`) or if the session serving them closes.
|
||||
* Collisions are handled by scoping connections to `(user_id, name)`.
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints are actions sent to the backend via the WebSocket.
|
||||
|
||||
### `remote-connect`
|
||||
Registers a new remote connection for the current user.
|
||||
|
||||
**Payload**:
|
||||
* `remotename` (string): The unique name for this remote collection.
|
||||
* `functions` (array of objects): List of function specs. Each object must contain:
|
||||
* `name` (string): The function identifier.
|
||||
* `description` (string): Text visible to the AI.
|
||||
* `parameters` (object): OpenAI tool parameter schema (`type`, `properties`, `required`).
|
||||
|
||||
### `remote-disconnect`
|
||||
Removes a connected remote.
|
||||
|
||||
**Payload**:
|
||||
* `remotename` (string): The name of the remote to remove.
|
||||
|
||||
### `remote-list`
|
||||
Requests the list of remotes currently registered for the authenticated user.
|
||||
|
||||
**Payload**: (Empty)
|
||||
|
||||
### `remote-call`
|
||||
Invokes a specific function on a remote.
|
||||
|
||||
**Payload**:
|
||||
* `remotename` (string): The target remote.
|
||||
* `function` (string): The function name.
|
||||
* `args` (object): The arguments to pass to the function, matching the schema defined in `remote-connect`.
|
||||
|
||||
### `remote-call-result`
|
||||
Sent by the server to the active session to deliver the outcome of a pending `remote-call`.
|
||||
|
||||
**Payload**:
|
||||
* `requestId` (string): A unique identifier for this specific request.
|
||||
* `result` (any): The return value of the function.
|
||||
* `error` (string): An error message if execution failed.
|
||||
|
||||
## Agent Integration
|
||||
|
||||
Remotes are integrated into the agent workflow in two stages: receiving context and executing tools.
|
||||
|
||||
### Chat Integration (`Agent.received_message`)
|
||||
When a user sends a message to a chat thread, the frontend sends a special system resource to mark which remotes should be available for that conversation.
|
||||
|
||||
* In `backend/src/agent.py`, the `received_message` method scans the thread's resources.
|
||||
* It looks for items with `subkind: 'remote'`.
|
||||
* It extracts the content of these resources (the remote names) and passes them into the agent run context.
|
||||
|
||||
### Execution Integration (`ExpertAgent.run`)
|
||||
In `backend/src/agents/expert.py`, the `run` method prepares for execution.
|
||||
|
||||
* It retrieves the list of remotes from the run input.
|
||||
* It calls `app.getRemoteTools(user_id, remotes)` to resolve these names into active tool specifications.
|
||||
* These tools are injected into the inference loop. When the AI selects a tool, the expert agent executes it. If a remote fails (timeout or error), the failure is returned as a string error, allowing the AI to retry or handle the failure naturally.
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Remote runtime: loads function modules from directories, watches them for
|
||||
// changes, and registers remotes on a horde connection.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const FUNCTION_EXTENSIONS = ['.mjs', '.js'];
|
||||
|
||||
// A directory of function modules. Each module exports `spec`
|
||||
// ({ name, description, parameters }) and a default async function (args, ctx).
|
||||
export class Functions {
|
||||
constructor(dir) {
|
||||
this.dir = dir;
|
||||
this.map = new Map(); // spec.name -> { spec, fn, file }
|
||||
}
|
||||
|
||||
async scan() {
|
||||
const map = new Map();
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(this.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
this.map = map; // no directory yet: no functions
|
||||
return map;
|
||||
}
|
||||
|
||||
// Load .mjs first so it wins over a same-named .js module
|
||||
entries.sort((a, b) =>
|
||||
FUNCTION_EXTENSIONS.indexOf(path.extname(a.name)) -
|
||||
FUNCTION_EXTENSIONS.indexOf(path.extname(b.name)));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!FUNCTION_EXTENSIONS.includes(path.extname(entry.name))) continue;
|
||||
const file = path.join(this.dir, entry.name);
|
||||
try {
|
||||
// Cache-busting query: node caches modules by URL
|
||||
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
||||
const { spec } = mod;
|
||||
if (!spec || typeof spec.name !== 'string') {
|
||||
throw new Error('module must export a `spec` object with a `name` string');
|
||||
}
|
||||
if (typeof mod.default !== 'function') {
|
||||
throw new Error('module must export a default function');
|
||||
}
|
||||
if (map.has(spec.name)) continue;
|
||||
map.set(spec.name, { spec, fn: mod.default, file });
|
||||
} catch (err) {
|
||||
console.error(`Failed to load function module ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.map = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
specs() {
|
||||
return [...this.map.values()].map((f) => f.spec);
|
||||
}
|
||||
|
||||
has(name) {
|
||||
return this.map.has(name);
|
||||
}
|
||||
|
||||
async call(name, args, ctx) {
|
||||
const fn = this.map.get(name);
|
||||
if (!fn) throw new Error(`Unknown function: ${name}`);
|
||||
return await fn.fn(args, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Watches a directory using fs.watch, with a polling fallback every 5 seconds.
|
||||
export class Watcher {
|
||||
constructor(dir, onChange, intervalMs = 5000) {
|
||||
this.dir = dir;
|
||||
this.onChange = onChange;
|
||||
this.intervalMs = intervalMs;
|
||||
this.fsWatcher = null;
|
||||
this.timer = null;
|
||||
this.signature = null;
|
||||
this.debounce = null;
|
||||
}
|
||||
|
||||
signatureOf() {
|
||||
try {
|
||||
return fs.readdirSync(this.dir, { withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => {
|
||||
const st = fs.statSync(path.join(this.dir, e.name));
|
||||
return `${e.name}:${st.mtimeMs}`;
|
||||
})
|
||||
.sort()
|
||||
.join('|');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
check() {
|
||||
const signature = this.signatureOf();
|
||||
if (signature !== this.signature) {
|
||||
const changed = this.signature !== null;
|
||||
this.signature = signature;
|
||||
if (changed) this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.signature = this.signatureOf();
|
||||
try {
|
||||
this.fsWatcher = fs.watch(this.dir, () => {
|
||||
if (this.debounce) clearTimeout(this.debounce);
|
||||
this.debounce = setTimeout(() => this.check(), 100);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`fs.watch unavailable for ${this.dir}, relying on polling: ${err.message}`);
|
||||
}
|
||||
this.timer = setInterval(() => this.check(), this.intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.fsWatcher) this.fsWatcher.close();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.debounce) clearTimeout(this.debounce);
|
||||
}
|
||||
}
|
||||
|
||||
// A named collection of functions in a directory, registered on the hub.
|
||||
export class Remote {
|
||||
constructor({ horde, name, functionsDir, ctx = null, fallback = null }) {
|
||||
this.horde = horde;
|
||||
this.name = name;
|
||||
this.ctx = ctx;
|
||||
this.fallback = fallback; // shared Functions instance (center functions)
|
||||
this.functions = new Functions(functionsDir);
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
// (Re)scans functions and (re)registers the remote. Returns success.
|
||||
async register() {
|
||||
await this.functions.scan();
|
||||
try {
|
||||
await this.horde.sendAction('remote-connect', {
|
||||
remotename: this.name,
|
||||
functions: this.functions.specs(),
|
||||
});
|
||||
console.log(`Registered remote '${this.name}' with ${this.functions.map.size} function(s)`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to register remote '${this.name}': ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (!this.watcher) {
|
||||
this.watcher = new Watcher(this.functions.dir, () => this.register());
|
||||
this.watcher.start();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.watcher) this.watcher.stop();
|
||||
try {
|
||||
await this.horde.sendAction('remote-disconnect', { remotename: this.name });
|
||||
} catch {
|
||||
// socket may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
// Handles an incoming remote-call. Falls back to shared center functions.
|
||||
async call(functionName, args) {
|
||||
if (this.functions.has(functionName)) {
|
||||
return await this.functions.call(functionName, args, this.ctx);
|
||||
}
|
||||
if (this.fallback) {
|
||||
return await this.fallback.call(functionName, args, this.ctx);
|
||||
}
|
||||
throw new Error(`Unknown function '${functionName}' on remote '${this.name}'`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user