diff --git a/package-lock.json b/package-lock.json index 87ff5215..3769c82e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,11 +35,12 @@ "react-dom": "^18.3.1", "reveal.js": "^5.2.1", "streamsaver": "^2.0.6", - "vue": "^3.5.13", + "vue": "^3.5.18", "vue-pdf-embed": "^2.1.2", "vue-router": "^4.5.0", "vue-virtual-scroller": "^2.0.0-beta.8", "vue3-toastify": "^0.2.8", + "vuew": "^1.0.0-security", "webworker-typed": "^1.0.5", "y-monaco": "^0.1.6", "y-prosemirror": "^1.2.17", @@ -411,18 +412,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -468,12 +469,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -1641,13 +1642,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -5834,53 +5835,53 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", + "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.28.0", + "@vue/shared": "3.5.18", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", + "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-core": "3.5.18", + "@vue/shared": "3.5.18" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", - "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", + "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.13", - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.28.0", + "@vue/compiler-core": "3.5.18", + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18", "estree-walker": "^2.0.2", - "magic-string": "^0.30.11", - "postcss": "^8.4.48", - "source-map-js": "^1.2.0" + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", - "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", + "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.18", + "@vue/shared": "3.5.18" } }, "node_modules/@vue/compiler-vue2": { @@ -5967,53 +5968,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", - "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz", + "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.13" + "@vue/shared": "3.5.18" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", - "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz", + "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/reactivity": "3.5.18", + "@vue/shared": "3.5.18" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", - "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", + "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/runtime-core": "3.5.13", - "@vue/shared": "3.5.13", + "@vue/reactivity": "3.5.18", + "@vue/runtime-core": "3.5.18", + "@vue/shared": "3.5.18", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", - "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz", + "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18" }, "peerDependencies": { - "vue": "3.5.13" + "vue": "3.5.18" } }, "node_modules/@vue/shared": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz", + "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", "license": "MIT" }, "node_modules/@vue/tsconfig": { @@ -15266,9 +15267,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -15285,7 +15286,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -18639,16 +18640,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", - "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz", + "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-sfc": "3.5.13", - "@vue/runtime-dom": "3.5.13", - "@vue/server-renderer": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-sfc": "3.5.18", + "@vue/runtime-dom": "3.5.18", + "@vue/server-renderer": "3.5.18", + "@vue/shared": "3.5.18" }, "peerDependencies": { "typescript": "*" @@ -18791,6 +18792,11 @@ } } }, + "node_modules/vuew": { + "version": "1.0.0-security", + "resolved": "https://registry.npmjs.org/vuew/-/vuew-1.0.0-security.tgz", + "integrity": "sha512-4jK3JubW7J+Kc4Hng5OK4tQCh+ESJpTGOBfsPWQ2svdEal6llacgjF0NuiqeZF9NYK7v2PJEXHRsR8i1i7ZpzA==" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 0f080dbc..c4fe1850 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,12 @@ "react-dom": "^18.3.1", "reveal.js": "^5.2.1", "streamsaver": "^2.0.6", - "vue": "^3.5.13", + "vue": "^3.5.18", "vue-pdf-embed": "^2.1.2", "vue-router": "^4.5.0", "vue-virtual-scroller": "^2.0.0-beta.8", "vue3-toastify": "^0.2.8", + "vuew": "^1.0.0-security", "webworker-typed": "^1.0.5", "y-monaco": "^0.1.6", "y-prosemirror": "^1.2.17", diff --git a/public/main.wasm b/public/main.wasm index 0f06ad52..65335c79 100755 Binary files a/public/main.wasm and b/public/main.wasm differ diff --git a/src/components/AddAgentModal.vue b/src/components/AddAgentModal.vue new file mode 100644 index 00000000..9fc9a5d5 --- /dev/null +++ b/src/components/AddAgentModal.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/AgentBrowser.vue b/src/components/AgentBrowser.vue new file mode 100644 index 00000000..75d07ede --- /dev/null +++ b/src/components/AgentBrowser.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/src/components/InvitePeopleModal.vue b/src/components/InvitePeopleModal.vue index 9d511fd8..41a769e4 100644 --- a/src/components/InvitePeopleModal.vue +++ b/src/components/InvitePeopleModal.vue @@ -133,6 +133,7 @@

{{ members.join('\n') }}

+ @@ -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 @@
- {{ item.user }} + + {{ item.user }} + AI + {{ formatTime(item) }}
-
+
+
@@ -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++; } } +