Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ep.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
"loadSettings": "ep_ai_chat/index",
"handleMessage": "ep_ai_chat/index",
"userJoin": "ep_ai_chat/index",
"socketio": "ep_ai_chat/index"
"socketio": "ep_ai_chat/index",
"clientVars": "ep_ai_chat/index"
},
"client_hooks": {
"postAceInit": "ep_ai_chat/static/js/index",
"chatSendMessage": "ep_ai_chat/static/js/index"
"chatSendMessage": "ep_ai_chat/static/js/index",
"chatPrefillFromUser": "ep_ai_chat/static/js/index"
}
}
]
Expand Down
17 changes: 17 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,23 @@ exports.socketio = (hookName, {io}) => {
};
exports.getSocketIo = () => socketioRef;

/**
* clientVars: expose the trigger string and the AI author's identity to
* the client. The chatPrefillFromUser client hook uses this to recognise
* the AI's row in the user list and substitute "@ai " for the otherwise
* useless "@AI_Assistant " prefill.
*/
exports.clientVars = async (hookName, context) => {
const aiId = await getAiAuthorId();
return {
ep_ai_chat: {
trigger: chatSettings.trigger,
authorName: chatSettings.authorName,
authorId: aiId,
},
};
};

/**
* userJoin: when a client joins a pad, push the AI's author info to the
* room so the AI shows up in the editors list immediately — without
Expand Down
21 changes: 21 additions & 0 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ exports.postAceInit = (hookName, context) => {
} catch (e) { /* never break ace init */ }
};

/**
* chatPrefillFromUser: when Etherpad core prefills "@<name> " in the chat
* input on a user-list click, swap in the configured trigger string for
* the AI's row. Without this, clicking the AI's chip in the user list
* would prefill "@AI_Assistant " which doesn't match anything the
* server-side mention extractor recognises.
*
* Requires the chatPrefillFromUser hook (Etherpad >= the version that
* landed ether/etherpad#7660). On older cores this hook is silently
* never called and clicks fall back to the default prefill.
*/
exports.chatPrefillFromUser = (hookName, context, cb) => {
try {
const ai = (window.clientVars && window.clientVars.ep_ai_chat) || {};
if (ai.authorId && context && context.authorId === ai.authorId) {
return cb(`${ai.trigger || '@ai'} `);
}
} catch (_e) { /* fall through to default */ }
return cb();
};

/**
* chatSendMessage: before a chat message is sent, capture the current
* selection in the pad and attach it to the message.
Expand Down
95 changes: 95 additions & 0 deletions static/tests/backend/specs/chat_prefill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict';

import {strict as assert} from 'assert';

/**
* The chatPrefillFromUser client hook — exercised here as a plain
* function (it's a CommonJS export from static/js/index.js) with a
* stubbed `window.clientVars`. Lets us cover the AI-vs-human branch
* without standing up a browser.
*
* The companion frontend test in Etherpad core (#7660) covers what
* the browser actually does with the returned string.
*/

const Module = require('module');

// The static/js client bundle requires nothing Node-specific, so we
// can require it directly. But it reads `window.clientVars`; install
// a global shim before requiring.
const installWindowShim = (clientVars: any) => {
// @ts-ignore
global.window = {clientVars};
};

const clearWindowShim = () => {
// @ts-ignore
delete global.window;
// Drop the client module from the require cache so a follow-up
// require() re-reads it under the new shim.
const path = require.resolve('../../../../static/js/index');
delete Module._cache[path];
};

describe('ep_ai_chat - chatPrefillFromUser hook handler', function () {
afterEach(() => clearWindowShim());

it('returns the configured trigger when the clicked user is the AI', function (done) {
installWindowShim({ep_ai_chat: {trigger: '@ai', authorId: 'a.ai_42'}});
const {chatPrefillFromUser} = require('../../../../static/js/index');
chatPrefillFromUser('chatPrefillFromUser', {
authorId: 'a.ai_42',
name: 'AI Assistant',
prefill: '@AI_Assistant ',
}, (out: any) => {
assert.equal(out, '@ai ');
done();
});
});

it('respects a custom trigger string', function (done) {
installWindowShim({ep_ai_chat: {trigger: '@bot', authorId: 'a.ai_99'}});
const {chatPrefillFromUser} = require('../../../../static/js/index');
chatPrefillFromUser('chatPrefillFromUser', {
authorId: 'a.ai_99',
name: 'AI Assistant',
prefill: '@AI_Assistant ',
}, (out: any) => {
assert.equal(out, '@bot ');
done();
});
});

it('falls back to default ("@ai ") if trigger is missing', function (done) {
installWindowShim({ep_ai_chat: {authorId: 'a.ai_42'}});
const {chatPrefillFromUser} = require('../../../../static/js/index');
chatPrefillFromUser('chatPrefillFromUser', {
authorId: 'a.ai_42', name: 'AI Assistant', prefill: '@AI_Assistant ',
}, (out: any) => {
assert.equal(out, '@ai ');
done();
});
});

it('returns nothing for a non-AI author so core uses its default', function (done) {
installWindowShim({ep_ai_chat: {trigger: '@ai', authorId: 'a.ai_42'}});
const {chatPrefillFromUser} = require('../../../../static/js/index');
chatPrefillFromUser('chatPrefillFromUser', {
authorId: 'a.alice_1', name: 'Alice', prefill: '@Alice ',
}, (out: any) => {
assert.equal(out, undefined);
done();
});
});

it('returns nothing when clientVars.ep_ai_chat is missing', function (done) {
installWindowShim({});
const {chatPrefillFromUser} = require('../../../../static/js/index');
chatPrefillFromUser('chatPrefillFromUser', {
authorId: 'a.ai_42', name: 'AI Assistant', prefill: '@AI_Assistant ',
}, (out: any) => {
assert.equal(out, undefined);
done();
});
});
});
Loading