+
@@ -420,6 +421,7 @@ async function send() {
Toast.success(`Invited ${pendingInvitees.value.length} users to workspace!`);
emit('close');
}
+
diff --git a/src/services/event-bus.ts b/src/services/event-bus.ts
index 07c68663..656ba433 100644
--- a/src/services/event-bus.ts
+++ b/src/services/event-bus.ts
@@ -1,7 +1,8 @@
import { EventEmitter } from 'events';
import type TypedEmitter from 'typed-emitter';
-import type { IChatChannel, IProject, IProjectFile } from './types';
+import type { IChatChannel, IProject, IProjectFile, IAgentChannel } from './types';
+
/**
* Global event bus for the application.
@@ -40,4 +41,10 @@ export const GlobalBus = new EventEmitter() as TypedEmitter<{
* The state is stored in _ndnd_conn_state
*/
'conn-change': () => void;
+
+ /**
+ * Event when agent channels are updated.
+ * @param channels List of agent channels
+ */
+ 'agent-channels': (channels: IAgentChannel[]) => void;
}>;
diff --git a/src/services/types.ts b/src/services/types.ts
index aecedd71..3ba5ae83 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -81,3 +81,53 @@ export type IProfile = {
/* OPTIONAL: Whether the user is the owner of the workspace */
owner?: boolean;
};
+
+
+
+/**
+ * AgentCard describes the metadata exposed by an A2A agent.
+ * The shape of this interface matches the common fields in the Agent-to-Agent specification.
+ * Additional fields may be added when needed.
+ */
+
+export interface IAgentCard {
+ /** Human readable name for the agent */
+ name: string;
+ /** Short description of the agent */
+ description: string;
+ /** Base URL where the agent is hosted */
+ url: string;
+
+ [key: string]: unknown;
+
+}
+
+
+/** Individual message exchanged in an agent channel. The role field distinguishes between messages sent by the user and those sent by the agent. */
+export interface IAgentMessage{
+ /** Unique identifier of the message */
+ uuid: string;
+ /** Identifier of the sender (user name or agent name)*/
+ user: string;
+ /** timestamp when the message was sent (epoch milliseconds) */
+ ts: number;
+ /** Content of the message */
+ message: string;
+ /** The role of the sender ('user' for human, 'agent' for replies) */
+ role: 'user' | 'agent';
+}
+
+
+
+/**
+ * A chat channel bound to a specific agent. Each agent channel keeps track of the agent card so calls can be routed correctly.
+ */
+export interface IAgentChannel {
+ /** Unique identifier for the channel */
+ uuid: string;
+ /** Display name for the channel */
+ name: string;
+ /** Reference to the agent card by its URL (used as ID) */
+ agentId: string;
+}
+
diff --git a/src/services/workspace-agent.ts b/src/services/workspace-agent.ts
new file mode 100644
index 00000000..161b1e36
--- /dev/null
+++ b/src/services/workspace-agent.ts
@@ -0,0 +1,324 @@
+import { EventEmitter } from 'events';
+import * as Y from 'yjs';
+import { nanoid } from 'nanoid'
+
+import { GlobalBus } from '@/services/event-bus';
+import type { SvsProvider } from '@/services/svs-provider';
+import type { WorkspaceAPI } from './ndn';
+import type TypedEmitter from 'typed-emitter';
+import type {IAgentCard, IAgentChannel, IAgentMessage, IChatMessage} from '@/services/types';
+import type {Workspace} from '@/services/workspace';
+
+
+/** WorkspaceAgent encapsulates discovery of agents, creation of dedicated channels and chat with those agents. It persists its state in a Yjs document backed by an SVS provider so that channel lists and chat history are replicated to peers via NDN */
+export class WorkspaceAgentManager{
+ /** List of all available agent cards */
+ private readonly agentCards: Y.Array;
+
+ private readonly channels: Y.Array;
+ private readonly history: Y.Map>;
+ public readonly events = new EventEmitter();
+
+
+ /** private constructor. instances should be created via the static {@link create} method which handles loading the underlying Yjs documents. */
+
+ private constructor(
+ private readonly api: WorkspaceAPI,
+ private readonly doc: Y.Doc,
+ private readonly provider: SvsProvider,
+ private readonly workspace: Workspace,
+
+
+ ) {
+ /** Event emitter to notify listenrs about new messages or channel changes.
+ * - 'chat' fires when a new message is added to any channel
+ * - 'channelAdded' fires when a new agent channel is created.
+ */
+ this.events = this.workspace.chat.events as TypedEmitter<{
+ chat: (channel: string, message: IChatMessage) => void;
+ channelAdded: (channel: IAgentChannel) => void;
+ }>;
+ this.agentCards = doc.getArray('_agent_cards_');
+ this.channels = doc.getArray('_agent_chan_');
+ this.history = doc.getMap>('_agent_msg_');
+
+ // Observe channel list changes and forward them onto the global bus.
+ const emitChannels = () => {
+ // Emit agent channels to a separate event
+ GlobalBus.emit('agent-channels', this.channels.toArray());
+ };
+ this.channels.observe(emitChannels);
+ //broadcast the current state of channels when the WorkspaceAgent is first created, ensuring the UI starts with the correct channel list.
+ emitChannels();
+
+ //Observe deep changes to the message map and notify local listeners.
+ this.history.observeDeep((events)=> {
+ if (this.events.listenerCount(('chat'))=== 0) return;
+ for (const ev of events){
+ if (ev.path.length > 0) {
+ const channelUuid = String(ev.path[0]);
+
+ // Find the channel name by UUID
+ const channelData = this.channels.toArray().find(ch => ch.uuid === channelUuid);
+ if (!channelData) {
+ console.warn('Channel not found for UUID:', channelUuid);
+ continue;
+ }
+ const channelName = channelData.name;
+
+ // Use Set.forEach instead of for...of
+ ev.changes.added.forEach(delta => {
+ try {
+ const content = delta.content.getContent();
+ const messages = Array.isArray(content) ? content : [content];
+
+ messages.forEach(msg => {
+ if (msg) {
+ this.events.emit('chat', channelName, msg as IAgentMessage);
+ }
+ });
+ } catch (error) {
+ console.warn('Error processing message delta:', error);
+ }
+ });
+ }
+ }
+ });
+
+ // Listen for chat messages to respond when agents are active in channels
+ // this.workspace.chat.events.addListener('chat', this.handleChatMessage.bind(this));
+ }
+ /**
+ * Create the agent service for a workspace. A Yjs doc name 'agent' will be loaded or created via the given provider.
+ * @param api WorkspaceAPI instance associated with teh workspave
+ * @param provider SVS provider used to persist and sync state
+ */
+ public static async create(api: WorkspaceAPI, provider: SvsProvider, workspace: Workspace): Promise {
+ const doc = await provider.getDoc('agent');
+ return new WorkspaceAgentManager(api, doc, provider, workspace);
+ }
+
+ /**
+ * Destroy the agent service and release its resources.
+ */
+ public async destroy() {
+ // this.workspace.chat.events.removeListener('chat', this.handleChatMessage.bind(this));
+ this.doc.destroy();
+ }
+
+ /**
+ * Get a snapshot of the current list of agent cards.
+ */
+ public getAgentCards(): IAgentCard[] {
+ return this.agentCards.toArray();
+ }
+
+ /**
+ * Add or update an agent card in the shared collection.
+ */
+ public addOrUpdateAgentCard(agentCard: IAgentCard): void {
+ const existingIndex = this.agentCards.toArray().findIndex(card => card.url === agentCard.url);
+
+ if (existingIndex >= 0) {
+ // Update existing card
+ this.agentCards.delete(existingIndex, 1);
+ this.agentCards.insert(existingIndex, [agentCard]);
+ } else {
+ // Add new card
+ this.agentCards.push([agentCard]);
+ }
+ }
+
+ /**
+ * Get an agent card by its URL (used as ID).
+ */
+ public getAgentCard(agentId: string): IAgentCard | undefined {
+ return this.agentCards.toArray().find(card => card.url === agentId);
+ }
+
+ /**
+ * Remove an agent card from the shared collection by URL.
+ */
+ public removeAgentCard(agentUrl: string): boolean {
+ const existingIndex = this.agentCards.toArray().findIndex(card => card.url === agentUrl);
+
+ if (existingIndex >= 0) {
+ this.agentCards.delete(existingIndex, 1);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get a snapshot of the current list of agent channels with resolved agent cards.
+ */
+ public async getChannels(): Promise<(IAgentChannel & { agent: IAgentCard })[]> {
+ return this.channels.toArray().map(channel => {
+ const agent = this.getAgentCard(channel.agentId);
+ if (!agent) {
+ throw new Error(`Agent card not found for channel ${channel.name}: ${channel.agentId}`);
+ }
+ return { ...channel, agent };
+ });
+ }
+
+ /**
+ * Invite an agent to join an existing chat channel.
+ * The agent will participate in the regular chat channel, not create a separate agent channel.
+ * Message history will be tracked for context purposes.
+ *
+ * @param agent The agent card to invite
+ * @param channelName The existing chat channel to invite the agent to
+ * */
+ public async inviteAgentToChannel(agent: IAgentCard, channelName: string): Promise {
+ // Check if chat channel exists
+ const chatChannels = await this.workspace.chat.getChannels();
+ const channel = chatChannels.find(ch => ch.name === channelName);
+
+ if (!channel) {
+ throw new Error(`Chat channel '${channelName}' not found`);
+ }
+
+ // Add/update the agent card in workspace
+ this.addOrUpdateAgentCard(agent);
+
+ // Create agent participation record for this channel
+ const agentParticipation: IAgentChannel = {
+ uuid: nanoid(),
+ name: channelName, // Same name as chat channel
+ agentId: agent.url,
+ };
+
+ // Track agent participation (allow multiple invitations)
+ this.channels.push([agentParticipation]);
+
+ // Initialize message history tracking for context (but don't create separate messages)
+ this.history.set(agentParticipation.uuid, new Y.Array());
+
+ // Send NDN invitation to agent
+ await this.workspace.invite.invokeAgent(channelName, agent.url);
+
+ // Add system message to the CHAT channel (not agent history)
+ await this.workspace.chat.sendMessage(channelName, {
+ uuid: nanoid(),
+ user: 'ownly-bot',
+ ts: Date.now(),
+ message: `${agent.name} agent has been invited to join #${channelName}`
+ });
+
+ console.log(`Agent ${agent.name} invited to chat channel #${channelName}`);
+ }
+
+ /**
+ * Get message history context for an agent in a specific channel
+ * This retrieves the last N messages
+ * It doesn't help for the client side, but if you are an agent, you can use this method to provide context.
+ */
+ public async getChannelContextForAgent(channelName: string, limit: number = 20): Promise {
+ // Get actual chat messages from the channel
+ const chatMessages = await this.workspace.chat.getMessages(channelName);
+
+ // Convert to agent message format and return last N messages
+ const contextMessages: IAgentMessage[] = chatMessages
+ .slice(-limit)
+ .map(msg => ({
+ uuid: msg.uuid,
+ user: msg.user,
+ ts: msg.ts,
+ message: msg.message,
+ role: 'user' // Assume human messages for context
+ }));
+
+ return contextMessages;
+ }
+
+ /**
+ * Get all agents participating in a specific chat channel
+ */
+ public getAgentsInChannel(channelName: string): IAgentCard[] {
+ const agentParticipations = this.channels.toArray().filter(ch => ch.name === channelName);
+ return agentParticipations
+ .map(participation => {
+ const agent = this.getAgentCard(participation.agentId);
+ if (!agent) {
+ console.warn(`Agent card not found for participation: ${participation.agentId}. Cleaning up orphaned participation.`);
+ // Clean up orphaned participation record
+ const participationIndex = this.channels.toArray().indexOf(participation);
+ if (participationIndex >= 0) {
+ this.channels.delete(participationIndex, 1);
+ }
+ return null;
+ }
+ return agent;
+ })
+ .filter((agent): agent is IAgentCard => agent !== null);
+ }
+
+ /**
+ * Remove an agent from a chat channel
+ * to REALLY remove it we still have to get the certificate expiration time correct and have server side support.
+ * Also, it works differently for HTTP VS NDN, so we should have a clear policy for both.
+ * This will be implemented in the future.
+ */
+ public async removeAgentFromChannel(agentId: string, channelName: string): Promise {
+ const participationIndex = this.channels.toArray().findIndex(ch =>
+ ch.name === channelName && ch.agentId === agentId
+ );
+
+ if (participationIndex === -1) {
+ throw new Error(`Agent not found in channel ${channelName}`);
+ }
+
+ const participation = this.channels.toArray()[participationIndex];
+
+ // Remove participation record and history
+ this.channels.delete(participationIndex);
+ this.history.delete(participation.uuid);
+
+ // Add system message to chat channel
+ const agent = this.getAgentCard(agentId);
+ if (agent) {
+ await this.workspace.chat.sendMessage(channelName, {
+ uuid: nanoid(),
+ user: 'ownly-bot',
+ ts: Date.now(),
+ message: `${agent.name} agent has left #${channelName}`
+ });
+ }
+ }
+
+
+ /** Get a snapshot of the message history for a channel */
+ public async getMessages(channelName: string): Promise{
+ const channel = this.channels.toArray().find(c => c.name === channelName);
+ if (!channel) throw new Error('Channel not found');
+
+ // Try to get messages by UUID first (new system)
+ let arr = this.history.get(channel.uuid);
+
+ // If no messages found by UUID, try the old channel name system (for backward compatibility)
+ if (!arr || arr.length === 0) {
+ const oldArr = this.history.get(channelName);
+ if (oldArr && oldArr.length > 0) {
+ // Migrate old messages to new UUID-based system
+ const messages = oldArr.toArray();
+ const newArr = new Y.Array();
+ newArr.insert(0, messages);
+ this.history.set(channel.uuid, newArr);
+
+ // Remove old messages (optional, for cleanup)
+ this.history.delete(channelName);
+
+ arr = newArr;
+ }
+ }
+
+ if (!arr) {
+ // Create empty array if no messages exist
+ arr = new Y.Array();
+ this.history.set(channel.uuid, arr);
+ }
+
+ return arr.toArray();
+ }
+}
diff --git a/src/services/workspace-invite.ts b/src/services/workspace-invite.ts
index caded98e..5f9a9d3c 100644
--- a/src/services/workspace-invite.ts
+++ b/src/services/workspace-invite.ts
@@ -57,6 +57,45 @@ export class WorkspaceInviteManager {
await this.invite(invitee.name); // Publish the invitation
}
+ /**
+ * Try to invite an agent to the workspace
+ *
+ * @param invitee Profile of the invitee
+ * @param inviteChannel The channel to assign
+ * @param inviteUrl The external server URL for the agent
+ */
+ public async invokeAgent(inviteChannel: string, inviteUrl: string): Promise {
+ if (!inviteUrl) {
+ console.warn("No inviteUrl provided for agent invite — skipping external request.");
+ return;
+ }
+
+ try {
+ const body = {
+ wkspName: this.wsmeta.name,
+ psk: this.wsmeta.psk,
+ channel: inviteChannel,
+ };
+
+ const response = await fetch(inviteUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status} ${response.statusText}`);
+ }
+
+ console.log(`Agent invite sent successfully to ${inviteUrl}`);
+ } catch (err) {
+ console.error(`Failed to send agent invite to ${inviteUrl}:`, err);
+ throw err; // rethrow so UI can display Toast error
+ }
+ }
+
/**
* Generate and publish an invitation for a name
*
diff --git a/src/services/workspace.ts b/src/services/workspace.ts
index bdb14c05..561743e8 100644
--- a/src/services/workspace.ts
+++ b/src/services/workspace.ts
@@ -1,9 +1,11 @@
import { WorkspaceChat } from './workspace-chat';
import { WorkspaceProj, WorkspaceProjManager } from './workspace-proj';
import { WorkspaceInviteManager } from './workspace-invite';
+import {WorkspaceAgentManager} from './workspace-agent'
import ndn from '@/services/ndn';
import { SvsProvider } from '@/services/svs-provider';
+
import { GlobalBus } from '@/services/event-bus';
import * as utils from '@/utils/index';
@@ -31,6 +33,7 @@ export class Workspace {
public readonly chat: WorkspaceChat,
public readonly proj: WorkspaceProjManager,
public readonly invite: WorkspaceInviteManager,
+ public readonly agent: WorkspaceAgentManager | null
) { }
/**
@@ -62,8 +65,16 @@ export class Workspace {
const proj = await WorkspaceProjManager.create(api, provider);
const invite = await WorkspaceInviteManager.create(api, metadata, provider);
- // Create workspace object
- return new Workspace(metadata, api, provider, chat, proj, invite);
+ // Create workspace object first (without agent)
+ const workspace = new Workspace(metadata, api, provider, chat, proj, invite, null);
+
+ // Then create agent with workspace reference
+ const agent = await WorkspaceAgentManager.create(api, provider, workspace);
+
+ // Update workspace with agent
+ (workspace as any).agent = agent;
+
+ return workspace;
} catch (e) {
// Clean up if we failed to start
api?.stop();
@@ -79,6 +90,9 @@ export class Workspace {
await this.proj.destroy();
await this.chat.destroy();
await this.provider?.destroy();
+ if (this.agent) {
+ await this.agent.destroy();
+ }
await this.api?.stop();
await this.invite.destroy();
}
@@ -200,7 +214,7 @@ export class Workspace {
// Generate DSK if creating a new workspace
const dsk = create ? new Uint8Array(32) : null;
- if (create) globalThis.crypto.getRandomValues(dsk);
+ if (create && dsk) globalThis.crypto.getRandomValues(dsk);
// Join workspace - this will check invitation etc.
const finalName = await ndn.api.join_workspace(wksp, create);
diff --git a/src/views/SpaceDiscussView.vue b/src/views/SpaceDiscussView.vue
index 0e81fd77..a699191d 100644
--- a/src/views/SpaceDiscussView.vue
+++ b/src/views/SpaceDiscussView.vue
@@ -42,11 +42,17 @@
@@ -130,8 +136,6 @@ const unreadCount = ref(0);
onMounted(async () => {
await setup();
-
- // Subscribe to chat messages
wksp.value?.chat.events.addListener('chat', onChatMessage);
});
@@ -145,6 +149,8 @@ watch(channelName, setup);
/** Set up the workspace and chat */
async function setup() {
try {
+ // Reset to loading state when switching channels
+ items.value = null;
// Set up the workspace
wksp.value = await Workspace.setupOrRedir(router);
if (!wksp.value) return;
@@ -152,8 +158,7 @@ async function setup() {
// Update tab name
document.title = utils.formTabName(wksp.value.metadata.label);
- // Load the chat messages
- items.value = null;
+ // Load regular chat channel messages (agents now participate in regular channels)
items.value = await wksp.value.chat.getMessages(channelName.value);
} catch (e) {
console.error(e);
@@ -167,10 +172,22 @@ async function setup() {
globalThis.setTimeout(() => scroller.value?.scrollToBottom(), 500); // uhh
}
+/** Check if a message is from an agent */
+function isAgentMessage(item: IChatMessage): boolean {
+ if (!wksp.value || !item) return false;
+ // Check if the user name matches any agents in this channel (make sure it's not null)
+ if (wksp.value.agent) {
+ const agents = wksp.value.agent.getAgentsInChannel(channelName.value) || [];
+ return agents.some(agent => agent.name === item.user);
+ }
+ return false;
+}
+
/** Skip the header if the user is the same and the message is within a minute */
function skipHeader(item: IChatMessage, index: number) {
- if (index === 0) return false;
- const prev = items.value![index - 1];
+ if (index === 0 || !item || !items.value) return false;
+ const prev = items.value[index - 1];
+ if (!prev) return false;
return prev.user === item.user && item.ts - prev.ts < 1000 * 60;
}
@@ -186,7 +203,9 @@ function formatTime(item: IChatMessage) {
hour: 'numeric',
minute: 'numeric',
});
- return (item.tsStr = formatter.format(new Date(item.ts)));
+ const formatted = formatter.format(new Date(item.ts));
+ item.tsStr = formatted;
+ return formatted;
}
/** Send a message to the workspace */
@@ -197,7 +216,7 @@ async function send(event: Event) {
}
if (!outMessage.value.trim()) return;
- // Send the message to the workspace
+ // Send message to regular chat channel (agents participate in same channels)
const message = {
uuid: String(), // auto
user: wksp.value!.username,
@@ -206,7 +225,7 @@ async function send(event: Event) {
};
await wksp.value?.chat.sendMessage(channelName.value, message);
- // Add the message to the chat and reset
+ // Reset the input
outMessage.value = String();
chatbox.value?.focus();
}
@@ -216,8 +235,6 @@ function onChatMessage(channel: string, message: IChatMessage) {
if (channel !== channelName.value) return; // not for us
// Add the message to the chat
- // This is done for both sender and receiver messages, so our
- // send() function does not actually update the UI
items.value!.push(message);
// Scroll to the bottom of the chat if the user is within 200px of the bottom
@@ -228,6 +245,7 @@ function onChatMessage(channel: string, message: IChatMessage) {
unreadCount.value++;
}
}
+