Skip to content

Commit 68cce00

Browse files
committed
feat: chatPrefillFromUser hook handler — substitute @ai for AI's display name
Override the default "@<name> " 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 ("@<name> ") 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.)
1 parent 9ccd1ae commit 68cce00

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

ep.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
"loadSettings": "ep_ai_chat/index",
77
"handleMessage": "ep_ai_chat/index",
88
"userJoin": "ep_ai_chat/index",
9-
"socketio": "ep_ai_chat/index"
9+
"socketio": "ep_ai_chat/index",
10+
"clientVars": "ep_ai_chat/index"
1011
},
1112
"client_hooks": {
1213
"postAceInit": "ep_ai_chat/static/js/index",
13-
"chatSendMessage": "ep_ai_chat/static/js/index"
14+
"chatSendMessage": "ep_ai_chat/static/js/index",
15+
"chatPrefillFromUser": "ep_ai_chat/static/js/index"
1416
}
1517
}
1618
]

index.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,23 @@ exports.socketio = (hookName, {io}) => {
152152
};
153153
exports.getSocketIo = () => socketioRef;
154154

155+
/**
156+
* clientVars: expose the trigger string and the AI author's identity to
157+
* the client. The chatPrefillFromUser client hook uses this to recognise
158+
* the AI's row in the user list and substitute "@ai " for the otherwise
159+
* useless "@AI_Assistant " prefill.
160+
*/
161+
exports.clientVars = async (hookName, context) => {
162+
const aiId = await getAiAuthorId();
163+
return {
164+
ep_ai_chat: {
165+
trigger: chatSettings.trigger,
166+
authorName: chatSettings.authorName,
167+
authorId: aiId,
168+
},
169+
};
170+
};
171+
155172
/**
156173
* userJoin: when a client joins a pad, push the AI's author info to the
157174
* room so the AI shows up in the editors list immediately — without

static/js/index.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ exports.postAceInit = (hookName, context) => {
2323
} catch (e) { /* never break ace init */ }
2424
};
2525

26+
/**
27+
* chatPrefillFromUser: when Etherpad core prefills "@<name> " in the chat
28+
* input on a user-list click, swap in the configured trigger string for
29+
* the AI's row. Without this, clicking the AI's chip in the user list
30+
* would prefill "@AI_Assistant " which doesn't match anything the
31+
* server-side mention extractor recognises.
32+
*
33+
* Requires the chatPrefillFromUser hook (Etherpad >= the version that
34+
* landed ether/etherpad#7660). On older cores this hook is silently
35+
* never called and clicks fall back to the default prefill.
36+
*/
37+
exports.chatPrefillFromUser = (hookName, context, cb) => {
38+
try {
39+
const ai = (window.clientVars && window.clientVars.ep_ai_chat) || {};
40+
if (ai.authorId && context && context.authorId === ai.authorId) {
41+
return cb(`${ai.trigger || '@ai'} `);
42+
}
43+
} catch (_e) { /* fall through to default */ }
44+
return cb();
45+
};
46+
2647
/**
2748
* chatSendMessage: before a chat message is sent, capture the current
2849
* selection in the pad and attach it to the message.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use strict';
2+
3+
import {strict as assert} from 'assert';
4+
5+
/**
6+
* The chatPrefillFromUser client hook — exercised here as a plain
7+
* function (it's a CommonJS export from static/js/index.js) with a
8+
* stubbed `window.clientVars`. Lets us cover the AI-vs-human branch
9+
* without standing up a browser.
10+
*
11+
* The companion frontend test in Etherpad core (#7660) covers what
12+
* the browser actually does with the returned string.
13+
*/
14+
15+
const Module = require('module');
16+
17+
// The static/js client bundle requires nothing Node-specific, so we
18+
// can require it directly. But it reads `window.clientVars`; install
19+
// a global shim before requiring.
20+
const installWindowShim = (clientVars: any) => {
21+
// @ts-ignore
22+
global.window = {clientVars};
23+
};
24+
25+
const clearWindowShim = () => {
26+
// @ts-ignore
27+
delete global.window;
28+
// Drop the client module from the require cache so a follow-up
29+
// require() re-reads it under the new shim.
30+
const path = require.resolve('../../../../static/js/index');
31+
delete Module._cache[path];
32+
};
33+
34+
describe('ep_ai_chat - chatPrefillFromUser hook handler', function () {
35+
afterEach(() => clearWindowShim());
36+
37+
it('returns the configured trigger when the clicked user is the AI', function (done) {
38+
installWindowShim({ep_ai_chat: {trigger: '@ai', authorId: 'a.ai_42'}});
39+
const {chatPrefillFromUser} = require('../../../../static/js/index');
40+
chatPrefillFromUser('chatPrefillFromUser', {
41+
authorId: 'a.ai_42',
42+
name: 'AI Assistant',
43+
prefill: '@AI_Assistant ',
44+
}, (out: any) => {
45+
assert.equal(out, '@ai ');
46+
done();
47+
});
48+
});
49+
50+
it('respects a custom trigger string', function (done) {
51+
installWindowShim({ep_ai_chat: {trigger: '@bot', authorId: 'a.ai_99'}});
52+
const {chatPrefillFromUser} = require('../../../../static/js/index');
53+
chatPrefillFromUser('chatPrefillFromUser', {
54+
authorId: 'a.ai_99',
55+
name: 'AI Assistant',
56+
prefill: '@AI_Assistant ',
57+
}, (out: any) => {
58+
assert.equal(out, '@bot ');
59+
done();
60+
});
61+
});
62+
63+
it('falls back to default ("@ai ") if trigger is missing', function (done) {
64+
installWindowShim({ep_ai_chat: {authorId: 'a.ai_42'}});
65+
const {chatPrefillFromUser} = require('../../../../static/js/index');
66+
chatPrefillFromUser('chatPrefillFromUser', {
67+
authorId: 'a.ai_42', name: 'AI Assistant', prefill: '@AI_Assistant ',
68+
}, (out: any) => {
69+
assert.equal(out, '@ai ');
70+
done();
71+
});
72+
});
73+
74+
it('returns nothing for a non-AI author so core uses its default', function (done) {
75+
installWindowShim({ep_ai_chat: {trigger: '@ai', authorId: 'a.ai_42'}});
76+
const {chatPrefillFromUser} = require('../../../../static/js/index');
77+
chatPrefillFromUser('chatPrefillFromUser', {
78+
authorId: 'a.alice_1', name: 'Alice', prefill: '@Alice ',
79+
}, (out: any) => {
80+
assert.equal(out, undefined);
81+
done();
82+
});
83+
});
84+
85+
it('returns nothing when clientVars.ep_ai_chat is missing', function (done) {
86+
installWindowShim({});
87+
const {chatPrefillFromUser} = require('../../../../static/js/index');
88+
chatPrefillFromUser('chatPrefillFromUser', {
89+
authorId: 'a.ai_42', name: 'AI Assistant', prefill: '@AI_Assistant ',
90+
}, (out: any) => {
91+
assert.equal(out, undefined);
92+
done();
93+
});
94+
});
95+
});

0 commit comments

Comments
 (0)