From 68cce0062c5c68c3a8342380a870cb82d7a9f608 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 05:46:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20chatPrefillFromUser=20hook=20handler=20?= =?UTF-8?q?=E2=80=94=20substitute=20@ai=20for=20AI's=20display=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override the default "@ " chat-input prefill with the configured AI trigger ("@ai " by default) when a user clicks the AI's row in the user list. Without this, clicking the AI chip would prefill "@AI_Assistant ", which doesn't match anything the server-side mention extractor recognises. What this PR adds: - clientVars server hook exposing { trigger, authorName, authorId } at clientVars.ep_ai_chat so the client can recognise the AI's row. - chatPrefillFromUser client hook that returns the trigger string when the clicked authorId matches the AI's; otherwise returns nothing so core's default ("@ ") wins. - Mocha unit test (static/tests/backend/specs/chat_prefill.ts) covering AI/non-AI/missing-clientVars/custom-trigger/missing-trigger paths. Depends on ether/etherpad#7660 which adds the chatPrefillFromUser hook to core. On older cores the hook is never fired and this PR is a benign no-op — graceful degradation, no install requirement bump. Originally this PR shipped the entire user-list click handler inside the plugin. Per review feedback, the generic "click a user → prefill @-mention" UX belongs in core (it's a discoverability win for any multi-user pad, AI or no AI), so the bulk moved to ether/etherpad#7660 and this PR is now ~40 lines of glue. (Replaces the previous 3-commit history on this branch with a clean rebase against the new main, which absorbed PRs #18 and #20 since the branch was opened.) --- ep.json | 6 +- index.js | 17 ++++ static/js/index.js | 21 +++++ static/tests/backend/specs/chat_prefill.ts | 95 ++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 static/tests/backend/specs/chat_prefill.ts diff --git a/ep.json b/ep.json index 6ec905d..c52ae9a 100644 --- a/ep.json +++ b/ep.json @@ -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" } } ] diff --git a/index.js b/index.js index f481d91..35ba410 100644 --- a/index.js +++ b/index.js @@ -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 diff --git a/static/js/index.js b/static/js/index.js index 272436f..4e6132b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -23,6 +23,27 @@ exports.postAceInit = (hookName, context) => { } catch (e) { /* never break ace init */ } }; +/** + * chatPrefillFromUser: when Etherpad core prefills "@ " 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. diff --git a/static/tests/backend/specs/chat_prefill.ts b/static/tests/backend/specs/chat_prefill.ts new file mode 100644 index 0000000..dadff8f --- /dev/null +++ b/static/tests/backend/specs/chat_prefill.ts @@ -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(); + }); + }); +});