-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpadEditor.js
More file actions
117 lines (106 loc) · 4.38 KB
/
padEditor.js
File metadata and controls
117 lines (106 loc) · 4.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
'use strict';
const Changeset = require('ep_etherpad-lite/static/js/Changeset');
const {Builder} = require('ep_etherpad-lite/static/js/Builder');
const padMessageHandler = require('ep_etherpad-lite/node/handler/PadMessageHandler');
const authorManager = require('ep_etherpad-lite/node/db/AuthorManager');
const log4js = require('ep_etherpad-lite/node_modules/log4js');
const {diffOps, countNewlines} = require('./surgicalDiff');
const logger = log4js.getLogger('ep_ai_chat:editor');
/**
* Broadcast AI author info to all clients on a pad so the AI
* appears in the user/author list with name and color.
*
* `io` is the socket.io server reference. The plugin's index.js
* captures it via the `socketio` hook and threads it down here so we
* don't depend on a non-existent module-level export.
*/
const announceAiAuthor = async (padId, authorId, io) => {
try {
const authorInfo = await authorManager.getAuthor(authorId);
if (!authorInfo || !io) {
logger.warn(
`announceAiAuthor: skipped — authorInfo=${!!authorInfo} io=${!!io}`);
return;
}
io.sockets.in(padId).emit('message', {
type: 'COLLABROOM',
data: {
type: 'USER_NEWINFO',
userInfo: {
colorId: authorInfo.colorId,
name: authorInfo.name,
userId: authorId,
},
},
});
} catch (err) {
logger.warn(`Failed to announce AI author: ${err.message}`);
}
};
/**
* Construct a changeset that turns currentText into currentText with
* findText (at idx) replaced by replaceText, but ONLY tagging the
* inserted runs with the AI's author attributes. Runs that already
* existed verbatim in findText keep their original authorship.
*/
const buildSurgicalChangeset = ({currentText, idx, edit, attribs, pool}) => {
const builder = new Builder(currentText.length);
const before = currentText.substring(0, idx);
const after = currentText.substring(idx + edit.findText.length);
if (before.length) builder.keepText(before);
for (const op of diffOps(edit.findText, edit.replaceText)) {
if (op.type === 'keep') {
builder.keepText(op.text);
} else if (op.type === 'remove') {
builder.remove(op.text.length, countNewlines(op.text));
} else if (op.type === 'insert') {
builder.insert(op.text, attribs, pool);
}
}
if (after.length) builder.keepText(after);
return builder.toString();
};
const applyEdit = async (pad, edit, io = null) => {
const currentText = pad.text();
const authorId = edit.authorId || '';
try {
// Build attributes: author for color/attribution, ep_ai_chat:requestedBy
// for provenance so phase B can resolve "my writing" later.
const attribList = [];
if (authorId) attribList.push(['author', authorId]);
if (edit.requesterAuthorId) {
attribList.push(['ep_ai_chat:requestedBy', edit.requesterAuthorId]);
}
const attribs = attribList.length ? attribList : undefined;
const pool = attribs ? pad.pool : undefined;
let changeset;
if (edit.appendText) {
const insertPos = currentText.length - 1;
changeset = Changeset.makeSplice(currentText, insertPos, 0, edit.appendText, attribs, pool);
} else if (edit.findText && edit.replaceText !== undefined) {
const idx = currentText.indexOf(edit.findText);
if (idx === -1) return {success: false, error: `Text not found: "${edit.findText.substring(0, 100)}"`};
// Diff findText -> replaceText so we only re-author the genuinely-
// changed runs. A single makeSplice would tag every char of
// replaceText with our author attribute even where the AI didn't
// actually rewrite anything (e.g. "we would <love> to play" ->
// "we would <deeply...> to play" must keep "we would" / "to play"
// attributed to whoever originally wrote them).
changeset = buildSurgicalChangeset({
currentText, idx, edit, attribs, pool,
});
} else {
return {success: false, error: 'No valid edit operation specified'};
}
await pad.appendRevision(changeset, authorId);
await padMessageHandler.updatePadClients(pad);
// Announce AI as an author so it appears in the user list
if (authorId) await announceAiAuthor(pad.id, authorId, io);
return {success: true};
} catch (err) {
logger.error(`Edit failed: ${err.message}`);
return {success: false, error: err.message};
}
};
exports.applyEdit = applyEdit;
exports.announceAiAuthor = announceAiAuthor;