openvibers/openvibe.chat Commits
Let server channels host owner Actor agents (#527)
Changes
@@@ -28,6 +28,13 @@ TRUST_PROXY=2# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)OV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# Actor agents in channels are on unless the owner turns them off in Chat settings.++# This env setting is an operator override that keeps them on.++#CHAT_ACTOR_MEMBERS=0++# V3 slash commands and server emoji are on by default; set either to 0 to turn it off.++#CHAT_SLASH_COMMANDS_ENABLED=1++#CHAT_SERVER_EMOJI_ENABLED=1++# Forum and stage channels are on by default; the owner can turn either off in Chat settings.# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.#OV_AI_INTERNAL_URL=http://127.0.0.1:4700#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150@@@ -342,7 +342,15 @@ quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (peopleonly; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A roommember can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an--account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.++account keeps the ordinary AI members it added (they belong to the room) without its subject on them, and exports them.++++**Actor agents in server channels** (`actor_members_enabled` in Chat owner settings, on by default; `CHAT_ACTOR_MEMBERS=1` forces it on): a server manager can attach one of their own++OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the++agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it++through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's++budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's++account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run`++and Actor must allow Chat to read only the personal agent tasks it started.**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answeris a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may@@@ -399,6 +407,11 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:comemberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels whennobody is.++Forum channels are available by default for a server manager who can connect a Community forum they moderate;++the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by++default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels++use Chat's voice rooms and are available by default; the owner can disable them with `stage_channels_enabled`.++**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person whomay post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, thefile goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands itnnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- A personal OpenVibe.Actor agent can join a server channel as an AI member. Its room task uses the existing++-- durable Actor poller, and the member id keeps the finished answer under the agent that did the work.++ALTER TABLE room_tasks ADD COLUMN IF NOT EXISTS actor_member_id bigint REFERENCES room_ai_members(id) ON DELETE SET NULL;++CREATE INDEX IF NOT EXISTS room_tasks_actor_member ON room_tasks (actor_member_id) WHERE actor_member_id IS NOT NULL;@@@ -190,12 +190,13 @@ const ERASE_AFTER_ROOMS = [['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],[null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],-- // An AI member belongs to its room, not to whoever added it: it stays, without their name on it.++ // An attached Actor agent belongs to its owner; an ordinary room AI member belongs to the room.++ ['actor_ai_members', "DELETE FROM room_ai_members WHERE link_service = 'actor' AND created_by_subject = ANY(@s)"],[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],// A hook queues work as the person who made it: it goes with them.['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],// A workspace room's queue keeps the work the team relies on, without who asked or who took it.-- [null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],++ [null, "UPDATE room_tasks SET requested_by = CASE WHEN requested_by = ANY(@ids) THEN 0 ELSE requested_by END, requested_subject = CASE WHEN requested_subject = ANY(@s) THEN NULL ELSE requested_subject END WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],@@@ -87,7 +87,7 @@ module.exports = {visitorTurnsPerDay: Math.max(1, int(process.env.CHAT_INBOX_VISITOR_TURNS_PER_DAY, 40)) },// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):// tasks for the person, with Chat's own token.-- actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },++ actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950'), members: bool(process.env.CHAT_ACTOR_MEMBERS) },// OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },@@@ -123,7 +123,7 @@ module.exports = {maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),},cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },-- slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, false) },++ slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, true) },// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.live: {@@@ -200,7 +200,7 @@ module.exports = {// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.emotes: {-- serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, false),++ serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, true),maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),@@@ -57,6 +57,7 @@ class AiMemberError extends Error { constructor(status, code, message) { super(mconst fail = (status, code, message) => { throw new AiMemberError(status, code, message); };const enabled = () => Boolean(config.ai.members && config.oauth.clientSecret);++const actorMembersEnabled = () => config.actor.members || require('../settings').value('actor_members_enabled', null, true) === true;async function list(room) {return await db.all('SELECT * FROM room_ai_members WHERE room_id = ? ORDER BY id', [room.id]);@@@ -99,11 +100,22 @@ async function engineOf(input, user) {async function add(room, user, can, input = {}) {if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');if (room.kind === 'system') fail(422, 'rooms.ai_kind', 'Announcement rooms have no AI members');-- const v = validate(input);++ let actorAgent = null;++ if (input.actor_agent_id != null) {++ if (!actorMembersEnabled() || !room.server_id) fail(403, 'rooms.actor_disabled', 'Actor agents in server channels are not enabled');++ if (!user || !user.subject_id) fail(403, 'rooms.actor_owner', 'Sign in as the agent\'s owner to add it');++ const found = await require('./vibe-agents').detail(user.subject_id, input.actor_agent_id);++ if (!found.agent || found.agent.id !== input.actor_agent_id) fail(422, 'rooms.actor_agent', found.error || 'Choose one of your own OpenVibe Agents');++ actorAgent = found.agent;++ }++ const actorName = actorAgent ? String(actorAgent.name || '').replace(/[^\p{L}\p{N} ._'-]+/gu, ' ').trim().slice(0, LIMITS.name).trim() : null;++ const v = validate(actorAgent ? { name: actorName.length >= 2 ? actorName : 'Actor agent' } : input);if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);-- const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;-- const e = (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };++ const link = actorAgent ? { service: 'actor', resource: actorAgent.id }++ : input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;++ const e = actorAgent ? { engine: 'openvibe', engine_subject: null, engine_label: null }++ : (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };const r = await db.run(`INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,@@@ -115,6 +127,7 @@ async function update(room, can, id, input = {}, user = null) {if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');const m = await byId(room, id);if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');++ if (m.link_service === 'actor' && input.engine && input.engine !== 'openvibe') fail(422, 'rooms.actor_engine', 'This member runs on OpenVibe Actor');const v = validate(input, { partial: true });if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);@@@ -385,6 +398,17 @@ async function afterPost(room, message, user, deps = {}) {broadcast({ type: 'room_message', room: room.slug, message: saved || posted });return saved || posted;};++ if (member.link_service === 'actor') {++ if (!actorMembersEnabled() || !room.server_id || !member.created_by_subject) return null;++ if (!personOk(room.id, user.id, Date.now())) return null;++ if (await repliesToday(room) >= config.ai.memberRepliesPerDay) return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);++ const task = await require('./workspace').addAgentMemberRun(room, user, member, message, deps).catch((err) => {++ console.warn('[Chat Actor member] start:', err && err.message);++ return null;++ });++ if (task) await countReply(room);++ return task;++ }if (inFlight.has(room.id)) return null;const own = member.engine === 'own' && member.engine_subject;if (vibe) {@@@ -522,7 +546,7 @@ function stop(roomId) {}/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */--const modelOf = (m) => (m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');++const modelOf = (m) => (m.link_service === 'actor' ? 'OpenVibe Actor' : m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');const toWire = (m) => ({id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),@@@ -532,4 +556,4 @@ const toWire = (m) => ({function _reset() { inFlight.clear(); personHour.clear(); stoppers.clear(); }--module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };++module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, actorMembersEnabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };@@@ -169,7 +169,7 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {try {const b = req.body || {};if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });-- if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });@@@ -167,9 +167,9 @@ async function insertChannel(server, user, { name, kind, topic = null, positionif (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');const roomKind = CHANNEL_KINDS[kind];if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');-- if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');++ if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');-- if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');++ if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, true) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');@@@ -20,6 +20,7 @@ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;let tokens = null;++let runTokens = null;function tokenClient() {if (tokens) return tokens;const { createServiceTokenClient } = require('openvibe-sdk/auth');@@@ -31,6 +32,15 @@ function tokenClient() {});return tokens;}++function runTokenClient() {++ if (runTokens) return runTokens;++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ runTokens = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.actor', scope: 'actor.agent.run',++ });++ return runTokens;++}/** What Vibe is told about an agent: no instructions back (it wrote them), and the page where the person manages it. */const wire = (a) => ({@@@ -41,11 +51,11 @@ const wire = (a) => ({page: `${ACTOR_SITE}/me/agents/${encodeURIComponent(a.id)}`,});--async function call(method, path, subject, body, { fetchImpl = globalThis.fetch } = {}) {++async function call(method, path, subject, body, { fetchImpl = globalThis.fetch, runScope = false } = {}) {if (!config.oauth.clientSecret) return { error: 'OpenVibe Agents are not available on this server' };if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };let token;-- try { token = await tokenClient().getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }++ try { token = await (runScope ? runTokenClient() : tokenClient()).getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }try {const res = await fetchImpl(`${config.actor.internalUrl}${path}`, {method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },@@@ -82,6 +92,22 @@ async function list(subject, deps = {}) {return { agents: agents.slice(0, 30).map(wire), manage: `${ACTOR_SITE}/me/agents` };}++/** The exact personal agent, verified by Actor against this person's subject. */++async function detail(subject, id, deps = {}) {++ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'Choose one of your own OpenVibe Agents' };++ const out = await call('GET', `/api/v1/me/agents/${encodeURIComponent(id)}`, subject, null, deps);++ return out.error ? out : { agent: out.json };++}++++/** Start a member's turn under its owner's Actor budget and daily cap. */++async function run(subject, id, input, deps = {}) {++ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'That agent is no longer available' };++ const out = await call('POST', `/api/v1/me/agents/${encodeURIComponent(id)}/run`, subject,++ { input: String(input || '').slice(0, 8000) }, { ...deps, runScope: true });++ return out.error ? out : { task: out.json };++}++function _setTokens(t) { tokens = t; }++function _setRunTokens(t) { runTokens = t; }--module.exports = { create, list, SCHEDULES, _setTokens };++module.exports = { create, list, detail, run, SCHEDULES, _setTokens, _setRunTokens };@@@ -135,6 +135,32 @@ async function add(room, user, { title, details = '', kind = 'research', via = 'return task;}++/** A server member asked an attached Actor agent. Its owner opted in when attaching it to this channel. */++async function addAgentMemberRun(room, user, member, message, deps = {}) {++ if (!require('./ai-members').actorMembersEnabled() || !room.server_id || member.link_service !== 'actor' || !member.created_by_subject)++ fail(403, 'work.actor_member_disabled', 'Actor agents are not enabled in this channel');++ const title = oneLine(String(message.message || '').replace(new RegExp(`@${member.handle}\\b`, 'i'), ''), LIMITS.title) || `Ask ${member.name}`;++ const history = await rooms.history(room, { limit: 16 });++ const context = history.map((m) => `${oneLine(m.display_name || m.username || 'Member', 40)}: ${oneLine(m.message, 500)}`).join('\n').slice(-6000);++ const input = `You are answering in the OpenVibe.Chat server channel #${room.name}. ${user.display_name || user.username || 'A member'} asked you. Your answer will be posted for everyone in that channel. Do not include private memory, credentials or account details in the public answer. Recent channel messages:\n${context}\n\nAnswer the latest message directly.`.slice(0, 8000);++ const inserted = await db.run(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, source_message, runner, actor_member_id)++ VALUES (?, ?, '', 'answer', ?, ?, 'api', ?, ?, ?)`,++ [room.id, title, user.id, member.created_by_subject, message.id, `agent:${member.link_resource}`, member.id]);++ const id = Number(inserted.lastInsertRowid);++ const agent = require('./vibe-agents');++ const started = await agent.run(member.created_by_subject, member.link_resource, input, deps);++ if (!started.task || !ACTOR_TASK.test(String(started.task.id || ''))) {++ await setStatus(room, id, { status: 'failed', error: oneLine(started.error || 'OpenVibe Actor did not start this agent', LIMITS.error), finished_at: nowText() });++ await announce(room, await row(room, id), deps).catch(() => {});++ return shape(await row(room, id), room);++ }++ await setStatus(room, id, { actor_task: started.task.id, status: 'running', started_at: nowText() });++ const task = shape(await row(room, id), room);++ await announceStep(room, await row(room, id), { status: 'running' });++ changed(room, task);++ return task;++}++async function setStatus(room, id, patch) {const sets = [];const vals = [];@@@ -353,7 +379,10 @@ async function showDesk(room, t, desk, deps = {}) {/** The room hears how a task ended, from its Vibe (or its first AI member), once. */async function announce(room, t, deps = {}) {if (!t || t.posted_message || !DONE.has(t.status)) { changed(room, t && shape(t, room)); return null; }-- const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);++ if (String(t.runner || '').startsWith('agent:') && !t.actor_member_id) { changed(room, shape(t, room)); return null; }++ const members = t.actor_member_id++ ? await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND id = ? LIMIT 1', [room.id, t.actor_member_id])++ : await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);const member = members[0];const who = t.requester_name || t.requester_username || 'someone';const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it@@@ -395,4 +424,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }function _setTokens(t) { tokens = t; }--module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };++module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, addAgentMemberRun, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };@@@ -39,17 +39,23 @@ const SCHEMA = Object.freeze({additionalProperties: false,properties: {forum_channels_enabled: {-- type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,++ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: true,description: 'Allow server owners to connect channels to Community forums.','x-required-for': 'Community backed forum channels','x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },},stage_channels_enabled: {-- type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,++ type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: true,description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.','x-required-for': 'Stage channels','x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },},++ actor_members_enabled: {++ type: 'boolean', title: 'Actor agents in channels', 'x-group': 'Channels', default: true,++ description: 'Allow server managers to attach their own Actor agents to channels. Members can ask them there; the agent owner pays from their Actor budget.',++ 'x-required-for': 'Actor agents as channel members',++ 'x-owner-input': { group: 'voices', unlocks: 'An owner controlled Actor agent can answer in a server channel.', get_it_at: 'https://openvibe.chat', get_it_how: 'Enable this Chat switch after the Actor read delegation and Network grant are deployed.' },++ },amazon_polly_access_key_id: {type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',@@@ -1235,6 +1235,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,const addRole = rooms.joinRoleOf(room);const attachments = can.manage ? await rooms.attachments(room) : [];const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];++ const actorMembersOn = !!(aiMembers.actorMembersEnabled() && room.server_id);++ const actorAgents = actorMembersOn && can.manage && actor.user.subject_id++ ? (await require('../rooms/vibe-agents').list(actor.user.subject_id)).agents || [] : [];let myKey = null;if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }const engineField = (a) => {@@@ -1243,7 +1246,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;};-- const aiForm = (a) => `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">++ const aiForm = (a) => a && a.link_service === 'actor'++ ? `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}"><p>Runs as your OpenVibe Actor agent. Its owner can change its instructions and budget on <a href="https://openvibe.actor/me/agents/${esc(a.link_resource)}">Actor</a>.</p><label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save</button></form>`++ : `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">${a ? '' : '<h3>Add an AI member</h3>'}<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label><label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>@@@ -1253,9 +1258,10 @@ ${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;const aiSection = can.manage && room.kind !== 'system' ? `<details class="oc-card oc-fold" id="oc-ai"${ais.length ? '' : ' open'}><summary><span>AI members${ais.length ? ` <span class="oc-muted">· ${ais.map((a) => esc(a.name)).join(', ')}</span>` : ''}</span>${IC.chevron}</summary>${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}--<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>++<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). Standard members use OpenVibe.AI or your own key and stay in chat. An attached Actor agent uses its owner's budget and approval rules, and posts its result here. At most ${aiMembers.MAX_PER_ROOM}.</p>${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove ${esc(a.name)}</span></button></form></section>`).join('')}${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ? `<form class="oc-form oc-ai-quick" method="post" action="/r/${esc(room.slug)}/ai-members"><input type="hidden" name="name" value="Vibe"><input type="hidden" name="persona" value="${esc(VIBE_IN_ROOMS)}"><p><button type="submit">Add Vibe</button> <span class="oc-muted">OpenVibe's assistant joins the room: anyone here can ask it with @vibe.</span></p></form>` : ''}++${actorMembersOn && ais.length < aiMembers.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/ai-members"><h3>Add one of your Actor agents</h3><p class="oc-muted">Anyone who can post here can ask it. Runs use your Actor budget and daily cap; the answer is public to this channel. Its approval rules still apply.</p><label>Agent <select name="actor_agent_id" required><option value="">Choose an agent</option>${actorAgents.map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><button class="ovk-btn ovk-btn--primary" type="submit"${actorAgents.length ? '' : ' disabled'}>Add agent</button>${actorAgents.length ? '' : '<p><a href="https://openvibe.actor/me/agents">Create an agent on Actor</a> first.</p>'}</form>` : ''}${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';const mineServers = (await require('../rooms/servers').list(actor.user)).mine;const listedRooms = await rooms.list(actor.user);@@@ -1511,7 +1517,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr}res.redirect(303, `/r/${room.slug}/settings?saved=1`);});-- router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine }); }));++ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine, actor_agent_id: b.actor_agent_id }); }));router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));@@@ -277,6 +277,10 @@ ${sheet}`,href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,})),}));++ if (ais.length) roleGroups.push({ title: 'Agents', people: ais.map((a) => ({++ username: `@${a.handle}`, display_name: a.name, roles: [require('../rooms/ai-members').modelOf(a)],++ href: `/s/${server.slug}/${pick.slug}#oc-compose`,++ })) });const canInvite = can.manage || (can.moderate && server.visibility === 'public');// The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).const inviteCode = String(req.query.invite || '').toLowerCase();@@@ -547,7 +551,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label><p><button type="submit">Save</button></p></form><h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>--<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, true) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, true) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, true) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2><p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>@@@ -593,7 +597,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth}));router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {const b = req.body || {};-- if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('Actor agents in Chat channels');++const AGENT = 'agt_01M4W5JBFEG3QG9ER8W1A2K0RX';++const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';++let h, owner, member, channel, actorCalls, actorResult;++let realFetch;++const serverApi = (method, path, user, body) => h.http(method, `/api/chat/servers${path}`, { token: user && user.token, body });++const roomApi = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });++++t('setup and owner switch', async () => {++ h = await boot({ env: { CHAT_ACTOR_MEMBERS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });++ owner = h.addUser('owner', { subject: ids.newId('user') });++ member = h.addUser('member', { subject: ids.newId('user') });++ for (const user of [owner, member]) await h.ctx.upsertUser(h.live.users.get(user.id));++ actorCalls = [];++ actorResult = { id: TASK, state: 'queued' };++ const ownerSubject = h.live.users.get(owner.id).subject_id;++ require('../server/rooms/vibe-agents')._setTokens({ getToken: async () => 'manage' });++ require('../server/rooms/vibe-agents')._setRunTokens({ getToken: async () => 'run' });++ require('../server/rooms/workspace')._setTokens({ getToken: async () => 'read' });++ realFetch = globalThis.fetch;++ globalThis.fetch = async (url, opts = {}) => {++ if (!String(url).startsWith('http://127.0.0.1:4950/')) return realFetch(url, opts);++ const path = new URL(url).pathname;++ actorCalls.push({ path, method: opts.method || 'GET', subject: opts.headers && opts.headers['X-OV-Subject'], body: opts.body ? JSON.parse(opts.body) : null });++ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++ if (path === `/api/v1/me/agents/${AGENT}` && opts.method !== 'POST') return opts.headers['X-OV-Subject'] === ownerSubject++ ? json({ id: AGENT, name: 'Researcher', status: 'active' }) : json({ code: 'actor.agent.not_found' }, 404);++ if (path === '/api/v1/me/agents') return json({ agents: [{ id: AGENT, name: 'Researcher', status: 'active', schedule: 'off' }] });++ if (path === `/api/v1/me/agents/${AGENT}/run`) return json({ id: TASK, state: 'queued' }, 201);++ if (path === `/api/v1/tasks/${TASK}`) return json(actorResult);++ return json({ code: 'not_found' }, 404);++ };++ const made = await serverApi('POST', '/', owner, { name: 'Build Club' });++ assert.strictEqual(made.status, 201, made.text);++ channel = (await serverApi('GET', '/build-club', owner)).body.channels[0].room;++ assert.strictEqual((await serverApi('POST', '/build-club/join', member)).status, 200);++ const config = require('../server/config');++ const settings = require('../server/settings');++ const previous = settings.current();++ config.actor.members = false;++ settings.activate({ value: (key, _env, fallback) => key === 'actor_members_enabled' ? false : fallback });++ try {++ assert.strictEqual((await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT })).status, 403);++ } finally { config.actor.members = true; settings.activate(previous); }++});++++t('owner attaches their agent and members can see and ask it', async () => {++ // The server channel page is exercised through HTTP below.++ assert.ok(require('../server/web/server-pages').mountServerPages);++ const attached = await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT });++ assert.strictEqual(attached.status, 201, attached.text);++ assert.deepStrictEqual([attached.body.name, attached.body.model, attached.body.link.service], ['Researcher', 'OpenVibe Actor', 'actor']);++ const page = await realFetch(`${h.base}/s/build-club/general`, { headers: { cookie: `ov_token=${member.token}` } });++ const html = await page.text();++ assert.strictEqual(page.status, 200);++ assert.match(html, /Agents <span class="oc-muted">1<\/span>/);++ assert.match(html, /@researcher/);++ const settingsPage = await realFetch(`${h.base}/r/${channel}/settings`, { headers: { cookie: `ov_token=${owner.token}` } });++ assert.strictEqual(settingsPage.status, 200);++ assert.match(await settingsPage.text(), /Add one of your Actor agents/);++ const posted = await roomApi('POST', `/${channel}/messages`, member, { message: '@researcher summarize our plan' });++ assert.strictEqual(posted.status, 201, posted.text);++ const db = require('../server/db/database');++ let task = null;++ for (let i = 0; i < 50; i++) {++ task = await db.get('SELECT * FROM room_tasks WHERE source_message = ?', [posted.body.message.id]);++ if (task && task.actor_task) break;++ await new Promise((resolve) => setTimeout(resolve, 40));++ }++ assert.ok(task && task.actor_task === TASK, 'the mention started a durable Actor task');++ const run = actorCalls.find((call) => call.path.endsWith(`/${AGENT}/run`));++ assert.strictEqual(run.subject, h.live.users.get(owner.id).subject_id, 'the attached agent runs on its owner account');++ assert.match(run.body.input, /summarize our plan/);++ actorResult = { id: TASK, state: 'succeeded', result: { answer: 'The plan is to build and test one feature.' }, cost: { usd: 0.003 } };++ await require('../server/rooms/workspace').poll();++ const messages = (await roomApi('GET', `/${channel}/messages`, member)).body.messages;++ assert.strictEqual(messages[0].display_name, 'Researcher');++ assert.match(messages[0].message, /The plan is to build and test one feature/);++ await require('../server/rooms/workspace').poll();++ assert.strictEqual((await roomApi('GET', `/${channel}/messages`, member)).body.messages.length, messages.length, 'one result after repeated polling');++});++++t.run(async () => { if (realFetch) globalThis.fetch = realFetch; if (h) await h.close(); });@@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);}-- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);++ assert.deepStrictEqual(Object.keys(props).sort(), ['actor_members_enabled', 'amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);const region = props.amazon_polly_region;assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);for (const v of region.enum) {@@@ -109,8 +109,13 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban met('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');-- assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'forum channels default off');-- assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');++ const settings = require('../server/settings');++ const previous = settings.current();++ settings.activate({ value: (key, _env, fallback) => ['forum_channels_enabled', 'stage_channels_enabled'].includes(key) ? false : fallback });++ try {++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'the owner can turn forum channels off');++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'the owner can turn stage channels off');++ } finally { settings.activate(previous); }let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
@@ -28,6 +28,13 @@ TRUST_PROXY=2
@@ -28,6 +28,13 @@ TRUST_PROXY=2
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# Actor agents in channels are on unless the owner turns them off in Chat settings.
+# This env setting is an operator override that keeps them on.
+#CHAT_ACTOR_MEMBERS=0
+# V3 slash commands and server emoji are on by default; set either to 0 to turn it off.
+#CHAT_SLASH_COMMANDS_ENABLED=1
+#CHAT_SERVER_EMOJI_ENABLED=1
+# Forum and stage channels are on by default; the owner can turn either off in Chat settings.
# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.
# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150
#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150
@@ -342,7 +342,15 @@ quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked
@@ -342,7 +342,15 @@ quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked
`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people
`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people
only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room
only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
-account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
+account keeps the ordinary AI members it added (they belong to the room) without its subject on them, and exports them.
+
+**Actor agents in server channels** (`actor_members_enabled` in Chat owner settings, on by default; `CHAT_ACTOR_MEMBERS=1` forces it on): a server manager can attach one of their own
+OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the
+agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it
+through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's
+budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's
+account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run`
+and Actor must allow Chat to read only the personal agent tasks it started.
**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
@@ -399,6 +407,11 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:co
@@ -399,6 +407,11 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:co
memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
nobody is.
nobody is.
+Forum channels are available by default for a server manager who can connect a Community forum they moderate;
+the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by
+default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels
+use Chat's voice rooms and are available by default; the owner can disable them with `stage_channels_enabled`.
+
**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who
**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who
may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the
may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the
file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it
file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- A personal OpenVibe.Actor agent can join a server channel as an AI member. Its room task uses the existing
+-- durable Actor poller, and the member id keeps the finished answer under the agent that did the work.
+ALTER TABLE room_tasks ADD COLUMN IF NOT EXISTS actor_member_id bigint REFERENCES room_ai_members(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS room_tasks_actor_member ON room_tasks (actor_member_id) WHERE actor_member_id IS NOT NULL;
@@ -190,12 +190,13 @@ const ERASE_AFTER_ROOMS = [
@@ -190,12 +190,13 @@ const ERASE_AFTER_ROOMS = [
['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
[null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
[null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
- // An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
+ // An attached Actor agent belongs to its owner; an ordinary room AI member belongs to the room.
+ ['actor_ai_members', "DELETE FROM room_ai_members WHERE link_service = 'actor' AND created_by_subject = ANY(@s)"],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
// A hook queues work as the person who made it: it goes with them.
// A hook queues work as the person who made it: it goes with them.
['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],
['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
- [null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
+ [null, "UPDATE room_tasks SET requested_by = CASE WHEN requested_by = ANY(@ids) THEN 0 ELSE requested_by END, requested_subject = CASE WHEN requested_subject = ANY(@s) THEN NULL ELSE requested_subject END WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
@@ -87,7 +87,7 @@ module.exports = {
@@ -87,7 +87,7 @@ module.exports = {
visitorTurnsPerDay: Math.max(1, int(process.env.CHAT_INBOX_VISITOR_TURNS_PER_DAY, 40)) },
visitorTurnsPerDay: Math.max(1, int(process.env.CHAT_INBOX_VISITOR_TURNS_PER_DAY, 40)) },
// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
// tasks for the person, with Chat's own token.
// tasks for the person, with Chat's own token.
- actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
+ actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950'), members: bool(process.env.CHAT_ACTOR_MEMBERS) },
// OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.
// OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.
today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },
today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },
@@ -123,7 +123,7 @@ module.exports = {
@@ -123,7 +123,7 @@ module.exports = {
maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
},
},
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
- slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, false) },
+ slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, true) },
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
live: {
live: {
@@ -200,7 +200,7 @@ module.exports = {
@@ -200,7 +200,7 @@ module.exports = {
// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
emotes: {
emotes: {
- serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, false),
+ serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, true),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
@@ -57,6 +57,7 @@ class AiMemberError extends Error { constructor(status, code, message) { super(m
@@ -57,6 +57,7 @@ class AiMemberError extends Error { constructor(status, code, message) { super(m
const fail = (status, code, message) => { throw new AiMemberError(status, code, message); };
const fail = (status, code, message) => { throw new AiMemberError(status, code, message); };
const enabled = () => Boolean(config.ai.members && config.oauth.clientSecret);
const enabled = () => Boolean(config.ai.members && config.oauth.clientSecret);
+const actorMembersEnabled = () => config.actor.members || require('../settings').value('actor_members_enabled', null, true) === true;
async function list(room) {
async function list(room) {
return await db.all('SELECT * FROM room_ai_members WHERE room_id = ? ORDER BY id', [room.id]);
return await db.all('SELECT * FROM room_ai_members WHERE room_id = ? ORDER BY id', [room.id]);
@@ -99,11 +100,22 @@ async function engineOf(input, user) {
@@ -99,11 +100,22 @@ async function engineOf(input, user) {
async function add(room, user, can, input = {}) {
async function add(room, user, can, input = {}) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
if (room.kind === 'system') fail(422, 'rooms.ai_kind', 'Announcement rooms have no AI members');
if (room.kind === 'system') fail(422, 'rooms.ai_kind', 'Announcement rooms have no AI members');
- const v = validate(input);
+ let actorAgent = null;
+ if (input.actor_agent_id != null) {
+ if (!actorMembersEnabled() || !room.server_id) fail(403, 'rooms.actor_disabled', 'Actor agents in server channels are not enabled');
+ if (!user || !user.subject_id) fail(403, 'rooms.actor_owner', 'Sign in as the agent\'s owner to add it');
+ const found = await require('./vibe-agents').detail(user.subject_id, input.actor_agent_id);
+ if (!found.agent || found.agent.id !== input.actor_agent_id) fail(422, 'rooms.actor_agent', found.error || 'Choose one of your own OpenVibe Agents');
+ actorAgent = found.agent;
+ }
+ const actorName = actorAgent ? String(actorAgent.name || '').replace(/[^\p{L}\p{N} ._'-]+/gu, ' ').trim().slice(0, LIMITS.name).trim() : null;
+ const v = validate(actorAgent ? { name: actorName.length >= 2 ? actorName : 'Actor agent' } : input);
if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
- const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
- const e = (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };
+ const link = actorAgent ? { service: 'actor', resource: actorAgent.id }
+ : input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
+ const e = actorAgent ? { engine: 'openvibe', engine_subject: null, engine_label: null }
+ : (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };
const r = await db.run(
const r = await db.run(
`INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)
`INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
@@ -115,6 +127,7 @@ async function update(room, can, id, input = {}, user = null) {
@@ -115,6 +127,7 @@ async function update(room, can, id, input = {}, user = null) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
const m = await byId(room, id);
const m = await byId(room, id);
if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
+ if (m.link_service === 'actor' && input.engine && input.engine !== 'openvibe') fail(422, 'rooms.actor_engine', 'This member runs on OpenVibe Actor');
const v = validate(input, { partial: true });
const v = validate(input, { partial: true });
if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
@@ -385,6 +398,17 @@ async function afterPost(room, message, user, deps = {}) {
@@ -385,6 +398,17 @@ async function afterPost(room, message, user, deps = {}) {
broadcast({ type: 'room_message', room: room.slug, message: saved || posted });
broadcast({ type: 'room_message', room: room.slug, message: saved || posted });
return saved || posted;
return saved || posted;
};
};
+ if (member.link_service === 'actor') {
+ if (!actorMembersEnabled() || !room.server_id || !member.created_by_subject) return null;
+ if (!personOk(room.id, user.id, Date.now())) return null;
+ if (await repliesToday(room) >= config.ai.memberRepliesPerDay) return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);
+ const task = await require('./workspace').addAgentMemberRun(room, user, member, message, deps).catch((err) => {
+ console.warn('[Chat Actor member] start:', err && err.message);
+ return null;
+ });
+ if (task) await countReply(room);
+ return task;
+ }
if (inFlight.has(room.id)) return null;
if (inFlight.has(room.id)) return null;
const own = member.engine === 'own' && member.engine_subject;
const own = member.engine === 'own' && member.engine_subject;
if (vibe) {
if (vibe) {
@@ -522,7 +546,7 @@ function stop(roomId) {
@@ -522,7 +546,7 @@ function stop(roomId) {
}
}
/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */
/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */
-const modelOf = (m) => (m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');
+const modelOf = (m) => (m.link_service === 'actor' ? 'OpenVibe Actor' : m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');
const toWire = (m) => ({
const toWire = (m) => ({
id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
@@ -532,4 +556,4 @@ const toWire = (m) => ({
@@ -532,4 +556,4 @@ const toWire = (m) => ({
function _reset() { inFlight.clear(); personHour.clear(); stoppers.clear(); }
function _reset() { inFlight.clear(); personHour.clear(); stoppers.clear(); }
-module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };
+module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, actorMembersEnabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };
@@ -169,7 +169,7 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
@@ -169,7 +169,7 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
try {
try {
const b = req.body || {};
const b = req.body || {};
if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });
if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });
- if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });
+ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });
if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });
if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });
if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));
if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));
const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });
const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });
@@ -167,9 +167,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
@@ -167,9 +167,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
const roomKind = CHANNEL_KINDS[kind];
const roomKind = CHANNEL_KINDS[kind];
if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');
if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');
- if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');
+ if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');
if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');
if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');
- if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
+ if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, true) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
@@ -20,6 +20,7 @@ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
@@ -20,6 +20,7 @@ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let tokens = null;
let tokens = null;
+let runTokens = null;
function tokenClient() {
function tokenClient() {
if (tokens) return tokens;
if (tokens) return tokens;
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@ -31,6 +32,15 @@ function tokenClient() {
@@ -31,6 +32,15 @@ function tokenClient() {
});
});
return tokens;
return tokens;
}
}
+function runTokenClient() {
+ if (runTokens) return runTokens;
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ runTokens = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.actor', scope: 'actor.agent.run',
+ });
+ return runTokens;
+}
/** What Vibe is told about an agent: no instructions back (it wrote them), and the page where the person manages it. */
/** What Vibe is told about an agent: no instructions back (it wrote them), and the page where the person manages it. */
const wire = (a) => ({
const wire = (a) => ({
@@ -41,11 +51,11 @@ const wire = (a) => ({
@@ -41,11 +51,11 @@ const wire = (a) => ({
page: `${ACTOR_SITE}/me/agents/${encodeURIComponent(a.id)}`,
page: `${ACTOR_SITE}/me/agents/${encodeURIComponent(a.id)}`,
});
});
-async function call(method, path, subject, body, { fetchImpl = globalThis.fetch } = {}) {
+async function call(method, path, subject, body, { fetchImpl = globalThis.fetch, runScope = false } = {}) {
if (!config.oauth.clientSecret) return { error: 'OpenVibe Agents are not available on this server' };
if (!config.oauth.clientSecret) return { error: 'OpenVibe Agents are not available on this server' };
if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };
if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };
let token;
let token;
- try { token = await tokenClient().getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }
+ try { token = await (runScope ? runTokenClient() : tokenClient()).getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }
try {
try {
const res = await fetchImpl(`${config.actor.internalUrl}${path}`, {
const res = await fetchImpl(`${config.actor.internalUrl}${path}`, {
method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
@@ -82,6 +92,22 @@ async function list(subject, deps = {}) {
@@ -82,6 +92,22 @@ async function list(subject, deps = {}) {
return { agents: agents.slice(0, 30).map(wire), manage: `${ACTOR_SITE}/me/agents` };
return { agents: agents.slice(0, 30).map(wire), manage: `${ACTOR_SITE}/me/agents` };
}
}
+/** The exact personal agent, verified by Actor against this person's subject. */
+async function detail(subject, id, deps = {}) {
+ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'Choose one of your own OpenVibe Agents' };
+ const out = await call('GET', `/api/v1/me/agents/${encodeURIComponent(id)}`, subject, null, deps);
+ return out.error ? out : { agent: out.json };
+}
+
+/** Start a member's turn under its owner's Actor budget and daily cap. */
+async function run(subject, id, input, deps = {}) {
+ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'That agent is no longer available' };
+ const out = await call('POST', `/api/v1/me/agents/${encodeURIComponent(id)}/run`, subject,
+ { input: String(input || '').slice(0, 8000) }, { ...deps, runScope: true });
+ return out.error ? out : { task: out.json };
+}
+
function _setTokens(t) { tokens = t; }
function _setTokens(t) { tokens = t; }
+function _setRunTokens(t) { runTokens = t; }
-module.exports = { create, list, SCHEDULES, _setTokens };
+module.exports = { create, list, detail, run, SCHEDULES, _setTokens, _setRunTokens };
@@ -135,6 +135,32 @@ async function add(room, user, { title, details = '', kind = 'research', via = '
@@ -135,6 +135,32 @@ async function add(room, user, { title, details = '', kind = 'research', via = '
return task;
return task;
}
}
+/** A server member asked an attached Actor agent. Its owner opted in when attaching it to this channel. */
+async function addAgentMemberRun(room, user, member, message, deps = {}) {
+ if (!require('./ai-members').actorMembersEnabled() || !room.server_id || member.link_service !== 'actor' || !member.created_by_subject)
+ fail(403, 'work.actor_member_disabled', 'Actor agents are not enabled in this channel');
+ const title = oneLine(String(message.message || '').replace(new RegExp(`@${member.handle}\\b`, 'i'), ''), LIMITS.title) || `Ask ${member.name}`;
+ const history = await rooms.history(room, { limit: 16 });
+ const context = history.map((m) => `${oneLine(m.display_name || m.username || 'Member', 40)}: ${oneLine(m.message, 500)}`).join('\n').slice(-6000);
+ const input = `You are answering in the OpenVibe.Chat server channel #${room.name}. ${user.display_name || user.username || 'A member'} asked you. Your answer will be posted for everyone in that channel. Do not include private memory, credentials or account details in the public answer. Recent channel messages:\n${context}\n\nAnswer the latest message directly.`.slice(0, 8000);
+ const inserted = await db.run(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, source_message, runner, actor_member_id)
+ VALUES (?, ?, '', 'answer', ?, ?, 'api', ?, ?, ?)`,
+ [room.id, title, user.id, member.created_by_subject, message.id, `agent:${member.link_resource}`, member.id]);
+ const id = Number(inserted.lastInsertRowid);
+ const agent = require('./vibe-agents');
+ const started = await agent.run(member.created_by_subject, member.link_resource, input, deps);
+ if (!started.task || !ACTOR_TASK.test(String(started.task.id || ''))) {
+ await setStatus(room, id, { status: 'failed', error: oneLine(started.error || 'OpenVibe Actor did not start this agent', LIMITS.error), finished_at: nowText() });
+ await announce(room, await row(room, id), deps).catch(() => {});
+ return shape(await row(room, id), room);
+ }
+ await setStatus(room, id, { actor_task: started.task.id, status: 'running', started_at: nowText() });
+ const task = shape(await row(room, id), room);
+ await announceStep(room, await row(room, id), { status: 'running' });
+ changed(room, task);
+ return task;
+}
+
async function setStatus(room, id, patch) {
async function setStatus(room, id, patch) {
const sets = [];
const sets = [];
const vals = [];
const vals = [];
@@ -353,7 +379,10 @@ async function showDesk(room, t, desk, deps = {}) {
@@ -353,7 +379,10 @@ async function showDesk(room, t, desk, deps = {}) {
/** The room hears how a task ended, from its Vibe (or its first AI member), once. */
/** The room hears how a task ended, from its Vibe (or its first AI member), once. */
async function announce(room, t, deps = {}) {
async function announce(room, t, deps = {}) {
if (!t || t.posted_message || !DONE.has(t.status)) { changed(room, t && shape(t, room)); return null; }
if (!t || t.posted_message || !DONE.has(t.status)) { changed(room, t && shape(t, room)); return null; }
- const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
+ if (String(t.runner || '').startsWith('agent:') && !t.actor_member_id) { changed(room, shape(t, room)); return null; }
+ const members = t.actor_member_id
+ ? await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND id = ? LIMIT 1', [room.id, t.actor_member_id])
+ : await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
const member = members[0];
const member = members[0];
const who = t.requester_name || t.requester_username || 'someone';
const who = t.requester_name || t.requester_username || 'someone';
const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it
const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it
@@ -395,4 +424,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -395,4 +424,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
function _setTokens(t) { tokens = t; }
-module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
+module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, addAgentMemberRun, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
@@ -39,17 +39,23 @@ const SCHEMA = Object.freeze({
@@ -39,17 +39,23 @@ const SCHEMA = Object.freeze({
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
forum_channels_enabled: {
forum_channels_enabled: {
- type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,
+ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: true,
description: 'Allow server owners to connect channels to Community forums.',
description: 'Allow server owners to connect channels to Community forums.',
'x-required-for': 'Community backed forum channels',
'x-required-for': 'Community backed forum channels',
'x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },
'x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },
},
},
stage_channels_enabled: {
stage_channels_enabled: {
- type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,
+ type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: true,
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
'x-required-for': 'Stage channels',
'x-required-for': 'Stage channels',
'x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },
'x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },
},
},
+ actor_members_enabled: {
+ type: 'boolean', title: 'Actor agents in channels', 'x-group': 'Channels', default: true,
+ description: 'Allow server managers to attach their own Actor agents to channels. Members can ask them there; the agent owner pays from their Actor budget.',
+ 'x-required-for': 'Actor agents as channel members',
+ 'x-owner-input': { group: 'voices', unlocks: 'An owner controlled Actor agent can answer in a server channel.', get_it_at: 'https://openvibe.chat', get_it_how: 'Enable this Chat switch after the Actor read delegation and Network grant are deployed.' },
+ },
amazon_polly_access_key_id: {
amazon_polly_access_key_id: {
type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
@@ -1235,6 +1235,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
@@ -1235,6 +1235,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
const addRole = rooms.joinRoleOf(room);
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
+ const actorMembersOn = !!(aiMembers.actorMembersEnabled() && room.server_id);
+ const actorAgents = actorMembersOn && can.manage && actor.user.subject_id
+ ? (await require('../rooms/vibe-agents').list(actor.user.subject_id)).agents || [] : [];
let myKey = null;
let myKey = null;
if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }
if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }
const engineField = (a) => {
const engineField = (a) => {
@@ -1243,7 +1246,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
@@ -1243,7 +1246,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>
return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>
${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
};
};
- const aiForm = (a) => `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
+ const aiForm = (a) => a && a.link_service === 'actor'
+ ? `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}"><p>Runs as your OpenVibe Actor agent. Its owner can change its instructions and budget on <a href="https://openvibe.actor/me/agents/${esc(a.link_resource)}">Actor</a>.</p><label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save</button></form>`
+ : `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
${a ? '' : '<h3>Add an AI member</h3>'}
${a ? '' : '<h3>Add an AI member</h3>'}
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
@@ -1253,9 +1258,10 @@ ${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${
@@ -1253,9 +1258,10 @@ ${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
const aiSection = can.manage && room.kind !== 'system' ? `<details class="oc-card oc-fold" id="oc-ai"${ais.length ? '' : ' open'}><summary><span>AI members${ais.length ? ` <span class="oc-muted">· ${ais.map((a) => esc(a.name)).join(', ')}</span>` : ''}</span>${IC.chevron}</summary>
const aiSection = can.manage && room.kind !== 'system' ? `<details class="oc-card oc-fold" id="oc-ai"${ais.length ? '' : ' open'}><summary><span>AI members${ais.length ? ` <span class="oc-muted">· ${ais.map((a) => esc(a.name)).join(', ')}</span>` : ''}</span>${IC.chevron}</summary>
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
-<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
+<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). Standard members use OpenVibe.AI or your own key and stay in chat. An attached Actor agent uses its owner's budget and approval rules, and posts its result here. At most ${aiMembers.MAX_PER_ROOM}.</p>
${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove ${esc(a.name)}</span></button></form></section>`).join('')}
${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove ${esc(a.name)}</span></button></form></section>`).join('')}
${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ? `<form class="oc-form oc-ai-quick" method="post" action="/r/${esc(room.slug)}/ai-members"><input type="hidden" name="name" value="Vibe"><input type="hidden" name="persona" value="${esc(VIBE_IN_ROOMS)}"><p><button type="submit">Add Vibe</button> <span class="oc-muted">OpenVibe's assistant joins the room: anyone here can ask it with @vibe.</span></p></form>` : ''}
${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ? `<form class="oc-form oc-ai-quick" method="post" action="/r/${esc(room.slug)}/ai-members"><input type="hidden" name="name" value="Vibe"><input type="hidden" name="persona" value="${esc(VIBE_IN_ROOMS)}"><p><button type="submit">Add Vibe</button> <span class="oc-muted">OpenVibe's assistant joins the room: anyone here can ask it with @vibe.</span></p></form>` : ''}
+${actorMembersOn && ais.length < aiMembers.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/ai-members"><h3>Add one of your Actor agents</h3><p class="oc-muted">Anyone who can post here can ask it. Runs use your Actor budget and daily cap; the answer is public to this channel. Its approval rules still apply.</p><label>Agent <select name="actor_agent_id" required><option value="">Choose an agent</option>${actorAgents.map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><button class="ovk-btn ovk-btn--primary" type="submit"${actorAgents.length ? '' : ' disabled'}>Add agent</button>${actorAgents.length ? '' : '<p><a href="https://openvibe.actor/me/agents">Create an agent on Actor</a> first.</p>'}</form>` : ''}
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
const listedRooms = await rooms.list(actor.user);
const listedRooms = await rooms.list(actor.user);
@@ -1511,7 +1517,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
@@ -1511,7 +1517,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
}
}
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
});
});
- router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine }); }));
+ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine, actor_agent_id: b.actor_agent_id }); }));
router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));
router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));
router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
@@ -277,6 +277,10 @@ ${sheet}`,
@@ -277,6 +277,10 @@ ${sheet}`,
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
})),
})),
}));
}));
+ if (ais.length) roleGroups.push({ title: 'Agents', people: ais.map((a) => ({
+ username: `@${a.handle}`, display_name: a.name, roles: [require('../rooms/ai-members').modelOf(a)],
+ href: `/s/${server.slug}/${pick.slug}#oc-compose`,
+ })) });
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
// The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).
// The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).
const inviteCode = String(req.query.invite || '').toLowerCase();
const inviteCode = String(req.query.invite || '').toLowerCase();
@@ -547,7 +551,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
@@ -547,7 +551,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
-<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, true) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, true) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, true) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}
${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
@@ -593,7 +597,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
@@ -593,7 +597,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
}));
}));
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
const b = req.body || {};
const b = req.body || {};
- if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });
+ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, true) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });
if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });
if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });
if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);
if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);
const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });
const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('Actor agents in Chat channels');
+const AGENT = 'agt_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';
+let h, owner, member, channel, actorCalls, actorResult;
+let realFetch;
+const serverApi = (method, path, user, body) => h.http(method, `/api/chat/servers${path}`, { token: user && user.token, body });
+const roomApi = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });
+
+t('setup and owner switch', async () => {
+ h = await boot({ env: { CHAT_ACTOR_MEMBERS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });
+ owner = h.addUser('owner', { subject: ids.newId('user') });
+ member = h.addUser('member', { subject: ids.newId('user') });
+ for (const user of [owner, member]) await h.ctx.upsertUser(h.live.users.get(user.id));
+ actorCalls = [];
+ actorResult = { id: TASK, state: 'queued' };
+ const ownerSubject = h.live.users.get(owner.id).subject_id;
+ require('../server/rooms/vibe-agents')._setTokens({ getToken: async () => 'manage' });
+ require('../server/rooms/vibe-agents')._setRunTokens({ getToken: async () => 'run' });
+ require('../server/rooms/workspace')._setTokens({ getToken: async () => 'read' });
+ realFetch = globalThis.fetch;
+ globalThis.fetch = async (url, opts = {}) => {
+ if (!String(url).startsWith('http://127.0.0.1:4950/')) return realFetch(url, opts);
+ const path = new URL(url).pathname;
+ actorCalls.push({ path, method: opts.method || 'GET', subject: opts.headers && opts.headers['X-OV-Subject'], body: opts.body ? JSON.parse(opts.body) : null });
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ if (path === `/api/v1/me/agents/${AGENT}` && opts.method !== 'POST') return opts.headers['X-OV-Subject'] === ownerSubject
+ ? json({ id: AGENT, name: 'Researcher', status: 'active' }) : json({ code: 'actor.agent.not_found' }, 404);
+ if (path === '/api/v1/me/agents') return json({ agents: [{ id: AGENT, name: 'Researcher', status: 'active', schedule: 'off' }] });
+ if (path === `/api/v1/me/agents/${AGENT}/run`) return json({ id: TASK, state: 'queued' }, 201);
+ if (path === `/api/v1/tasks/${TASK}`) return json(actorResult);
+ return json({ code: 'not_found' }, 404);
+ };
+ const made = await serverApi('POST', '/', owner, { name: 'Build Club' });
+ assert.strictEqual(made.status, 201, made.text);
+ channel = (await serverApi('GET', '/build-club', owner)).body.channels[0].room;
+ assert.strictEqual((await serverApi('POST', '/build-club/join', member)).status, 200);
+ const config = require('../server/config');
+ const settings = require('../server/settings');
+ const previous = settings.current();
+ config.actor.members = false;
+ settings.activate({ value: (key, _env, fallback) => key === 'actor_members_enabled' ? false : fallback });
+ try {
+ assert.strictEqual((await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT })).status, 403);
+ } finally { config.actor.members = true; settings.activate(previous); }
+});
+
+t('owner attaches their agent and members can see and ask it', async () => {
+ // The server channel page is exercised through HTTP below.
+ assert.ok(require('../server/web/server-pages').mountServerPages);
+ const attached = await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT });
+ assert.strictEqual(attached.status, 201, attached.text);
+ assert.deepStrictEqual([attached.body.name, attached.body.model, attached.body.link.service], ['Researcher', 'OpenVibe Actor', 'actor']);
+ const page = await realFetch(`${h.base}/s/build-club/general`, { headers: { cookie: `ov_token=${member.token}` } });
+ const html = await page.text();
+ assert.strictEqual(page.status, 200);
+ assert.match(html, /Agents <span class="oc-muted">1<\/span>/);
+ assert.match(html, /@researcher/);
+ const settingsPage = await realFetch(`${h.base}/r/${channel}/settings`, { headers: { cookie: `ov_token=${owner.token}` } });
+ assert.strictEqual(settingsPage.status, 200);
+ assert.match(await settingsPage.text(), /Add one of your Actor agents/);
+ const posted = await roomApi('POST', `/${channel}/messages`, member, { message: '@researcher summarize our plan' });
+ assert.strictEqual(posted.status, 201, posted.text);
+ const db = require('../server/db/database');
+ let task = null;
+ for (let i = 0; i < 50; i++) {
+ task = await db.get('SELECT * FROM room_tasks WHERE source_message = ?', [posted.body.message.id]);
+ if (task && task.actor_task) break;
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ }
+ assert.ok(task && task.actor_task === TASK, 'the mention started a durable Actor task');
+ const run = actorCalls.find((call) => call.path.endsWith(`/${AGENT}/run`));
+ assert.strictEqual(run.subject, h.live.users.get(owner.id).subject_id, 'the attached agent runs on its owner account');
+ assert.match(run.body.input, /summarize our plan/);
+ actorResult = { id: TASK, state: 'succeeded', result: { answer: 'The plan is to build and test one feature.' }, cost: { usd: 0.003 } };
+ await require('../server/rooms/workspace').poll();
+ const messages = (await roomApi('GET', `/${channel}/messages`, member)).body.messages;
+ assert.strictEqual(messages[0].display_name, 'Researcher');
+ assert.match(messages[0].message, /The plan is to build and test one feature/);
+ await require('../server/rooms/workspace').poll();
+ assert.strictEqual((await roomApi('GET', `/${channel}/messages`, member)).body.messages.length, messages.length, 'one result after repeated polling');
+});
+
+t.run(async () => { if (realFetch) globalThis.fetch = realFetch; if (h) await h.close(); });
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
}
}
- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);
+ assert.deepStrictEqual(Object.keys(props).sort(), ['actor_members_enabled', 'amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);
const region = props.amazon_polly_region;
const region = props.amazon_polly_region;
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
for (const v of region.enum) {
for (const v of region.enum) {
@@ -109,8 +109,13 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
@@ -109,8 +109,13 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
- assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'forum channels default off');
- assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');
+ const settings = require('../server/settings');
+ const previous = settings.current();
+ settings.activate({ value: (key, _env, fallback) => ['forum_channels_enabled', 'stage_channels_enabled'].includes(key) ? false : fallback });
+ try {
+ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'the owner can turn forum channels off');
+ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'the owner can turn stage channels off');
+ } finally { settings.activate(previous); }
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });