mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-19 15:55:20 +00:00
299 lines
8.4 KiB
JavaScript
299 lines
8.4 KiB
JavaScript
/*
|
|
Here we initialize the plugin with keyword mapping.
|
|
Intended to handle user interactions seamlessly.
|
|
|
|
Observe the keydown events for proactive suggestions.
|
|
Provide a mechanism for accepting AI suggestions.
|
|
Evaluate each input change with debounce logic.
|
|
Next, we implement touch and mouse interactions.
|
|
|
|
Anchor the user experience to intuitive behavior.
|
|
Intelligently reset suggestions on new input.
|
|
*/
|
|
|
|
import { Extension } from '@tiptap/core';
|
|
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
|
|
export const AIAutocompletion = Extension.create({
|
|
name: 'aiAutocompletion',
|
|
|
|
addOptions() {
|
|
return {
|
|
generateCompletion: () => Promise.resolve(''),
|
|
debounceTime: 1000
|
|
};
|
|
},
|
|
|
|
addGlobalAttributes() {
|
|
return [
|
|
{
|
|
types: ['paragraph'],
|
|
attributes: {
|
|
class: {
|
|
default: null,
|
|
parseHTML: (element) => element.getAttribute('class'),
|
|
renderHTML: (attributes) => {
|
|
if (!attributes.class) return {};
|
|
return { class: attributes.class };
|
|
}
|
|
},
|
|
'data-prompt': {
|
|
default: null,
|
|
parseHTML: (element) => element.getAttribute('data-prompt'),
|
|
renderHTML: (attributes) => {
|
|
if (!attributes['data-prompt']) return {};
|
|
return { 'data-prompt': attributes['data-prompt'] };
|
|
}
|
|
},
|
|
'data-suggestion': {
|
|
default: null,
|
|
parseHTML: (element) => element.getAttribute('data-suggestion'),
|
|
renderHTML: (attributes) => {
|
|
if (!attributes['data-suggestion']) return {};
|
|
return { 'data-suggestion': attributes['data-suggestion'] };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
];
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
let debounceTimer = null;
|
|
let loading = false;
|
|
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
|
|
let isComposing = false;
|
|
|
|
const handleAICompletion = (view) => {
|
|
const { state, dispatch } = view;
|
|
const { selection } = state;
|
|
const { $head } = selection;
|
|
|
|
// Start debounce logic for AI generation only if the cursor is at the end of the paragraph
|
|
if (selection.empty && $head.pos === $head.end()) {
|
|
// Set up debounce for AI generation
|
|
if (this.options.debounceTime !== null) {
|
|
clearTimeout(debounceTimer);
|
|
|
|
// Capture current position
|
|
const currentPos = $head.before();
|
|
|
|
debounceTimer = setTimeout(() => {
|
|
if (isComposing) return false;
|
|
|
|
const newState = view.state;
|
|
const newSelection = newState.selection;
|
|
const newNode = newState.doc.nodeAt(currentPos);
|
|
|
|
// Check if the node still exists and is still a paragraph
|
|
if (
|
|
newNode &&
|
|
newNode.type.name === 'paragraph' &&
|
|
newSelection.$head.pos === newSelection.$head.end() &&
|
|
newSelection.$head.pos === currentPos + newNode.nodeSize - 1
|
|
) {
|
|
const prompt = newNode.textContent;
|
|
|
|
if (prompt.trim() !== '') {
|
|
if (loading) return true;
|
|
loading = true;
|
|
this.options
|
|
.generateCompletion(prompt)
|
|
.then((suggestion) => {
|
|
if (suggestion && suggestion.trim() !== '') {
|
|
if (view.state.selection.$head.pos === view.state.selection.$head.end()) {
|
|
if (view.state === newState) {
|
|
view.dispatch(
|
|
newState.tr.setNodeMarkup(currentPos, null, {
|
|
...newNode.attrs,
|
|
class: 'ai-autocompletion',
|
|
'data-prompt': prompt,
|
|
'data-suggestion': suggestion
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.finally(() => {
|
|
loading = false;
|
|
});
|
|
}
|
|
}
|
|
}, this.options.debounceTime);
|
|
}
|
|
}
|
|
};
|
|
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey('aiAutocompletion'),
|
|
props: {
|
|
handleKeyDown: (view, event) => {
|
|
const { state, dispatch } = view;
|
|
const { selection } = state;
|
|
const { $head } = selection;
|
|
|
|
if ($head.parent.type.name !== 'paragraph') return false;
|
|
|
|
const node = $head.parent;
|
|
|
|
if (event.key === 'Tab') {
|
|
// if (!node.attrs['data-suggestion']) {
|
|
// // Generate completion
|
|
// if (loading) return true
|
|
// loading = true
|
|
// const prompt = node.textContent
|
|
// this.options.generateCompletion(prompt).then(suggestion => {
|
|
// if (suggestion && suggestion.trim() !== '') {
|
|
// dispatch(state.tr.setNodeMarkup($head.before(), null, {
|
|
// ...node.attrs,
|
|
// class: 'ai-autocompletion',
|
|
// 'data-prompt': prompt,
|
|
// 'data-suggestion': suggestion,
|
|
// }))
|
|
// }
|
|
// // If suggestion is empty or null, do nothing
|
|
// }).finally(() => {
|
|
// loading = false
|
|
// })
|
|
// }
|
|
|
|
if (node.attrs['data-suggestion']) {
|
|
// Accept suggestion
|
|
const suggestion = node.attrs['data-suggestion'];
|
|
dispatch(
|
|
state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, {
|
|
...node.attrs,
|
|
class: null,
|
|
'data-prompt': null,
|
|
'data-suggestion': null
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
} else {
|
|
if (node.attrs['data-suggestion']) {
|
|
// Reset suggestion on any other key press
|
|
dispatch(
|
|
state.tr.setNodeMarkup($head.before(), null, {
|
|
...node.attrs,
|
|
class: null,
|
|
'data-prompt': null,
|
|
'data-suggestion': null
|
|
})
|
|
);
|
|
}
|
|
|
|
handleAICompletion(view);
|
|
}
|
|
return false;
|
|
},
|
|
handleDOMEvents: {
|
|
compositionstart: () => {
|
|
isComposing = true;
|
|
return false;
|
|
},
|
|
compositionend: (view) => {
|
|
isComposing = false;
|
|
handleAICompletion(view);
|
|
return false;
|
|
},
|
|
touchstart: (view, event) => {
|
|
touchStartX = event.touches[0].clientX;
|
|
touchStartY = event.touches[0].clientY;
|
|
return false;
|
|
},
|
|
touchend: (view, event) => {
|
|
const touchEndX = event.changedTouches[0].clientX;
|
|
const touchEndY = event.changedTouches[0].clientY;
|
|
|
|
const deltaX = touchEndX - touchStartX;
|
|
const deltaY = touchEndY - touchStartY;
|
|
|
|
// Check if the swipe was primarily horizontal and to the right
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) {
|
|
const { state, dispatch } = view;
|
|
const { selection } = state;
|
|
const { $head } = selection;
|
|
const node = $head.parent;
|
|
|
|
if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) {
|
|
const suggestion = node.attrs['data-suggestion'];
|
|
dispatch(
|
|
state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, {
|
|
...node.attrs,
|
|
class: null,
|
|
'data-prompt': null,
|
|
'data-suggestion': null
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
// Add mousedown behavior
|
|
// mouseup: (view, event) => {
|
|
// const { state, dispatch } = view;
|
|
// const { selection } = state;
|
|
// const { $head } = selection;
|
|
// const node = $head.parent;
|
|
|
|
// // Reset debounce timer on mouse click
|
|
// clearTimeout(debounceTimer);
|
|
|
|
// // If a suggestion exists and the cursor moves, remove the suggestion
|
|
// if (
|
|
// node.type.name === 'paragraph' &&
|
|
// node.attrs['data-suggestion'] &&
|
|
// view.state.selection.$head.pos !== view.state.selection.$head.end()
|
|
// ) {
|
|
// dispatch(
|
|
// state.tr.setNodeMarkup($head.before(), null, {
|
|
// ...node.attrs,
|
|
// class: null,
|
|
// 'data-prompt': null,
|
|
// 'data-suggestion': null
|
|
// })
|
|
// );
|
|
// }
|
|
|
|
// return false;
|
|
// }
|
|
mouseup: (view, event) => {
|
|
const { state, dispatch } = view;
|
|
|
|
// Reset debounce timer on mouse click
|
|
clearTimeout(debounceTimer);
|
|
|
|
// Iterate over all nodes in the document
|
|
const tr = state.tr;
|
|
state.doc.descendants((node, pos) => {
|
|
if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) {
|
|
// Remove suggestion from this paragraph
|
|
tr.setNodeMarkup(pos, null, {
|
|
...node.attrs,
|
|
class: null,
|
|
'data-prompt': null,
|
|
'data-suggestion': null
|
|
});
|
|
}
|
|
});
|
|
|
|
// Apply the transaction if any changes were made
|
|
if (tr.docChanged) {
|
|
dispatch(tr);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
];
|
|
}
|
|
});
|