diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index bf34d6d343..2d4bc38dd8 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -28,6 +28,9 @@ "./file-zipper": { "import": "./dist/file-zipper.es.js" }, + "./ai-writer": { + "import": "./dist/ai-writer.es.js" + }, "./style.css": "./dist/style.css" }, "main": "./dist/super-editor.es.js", diff --git a/packages/super-editor/src/assets/styles/elements/_all.css b/packages/super-editor/src/assets/styles/elements/_all.css index eac250fb03..d357dbbba9 100644 --- a/packages/super-editor/src/assets/styles/elements/_all.css +++ b/packages/super-editor/src/assets/styles/elements/_all.css @@ -1,2 +1,3 @@ @import './prosemirror.css'; @import './toolbar.css'; +@import './toolbar-custom.css'; diff --git a/packages/super-editor/src/assets/styles/elements/toolbar-custom.css b/packages/super-editor/src/assets/styles/elements/toolbar-custom.css new file mode 100644 index 0000000000..16dc7d5472 --- /dev/null +++ b/packages/super-editor/src/assets/styles/elements/toolbar-custom.css @@ -0,0 +1,59 @@ +/* Custom toolbar styling */ + +/* AI button icon styling with gradient */ +.toolbar-icon__icon--ai { + position: relative; + z-index: 1; +} + +.toolbar-icon__icon--ai svg { + fill: transparent; +} + +.toolbar-icon__icon--ai::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + background: linear-gradient( + 270deg, + rgba(218, 215, 118, 0.5) -20%, + rgba(191, 100, 100, 1) 30%, + rgba(77, 82, 217, 1) 60%, + rgb(255, 219, 102) 150% + ); + -webkit-mask: url("data:image/svg+xml;charset=utf-8,") center / contain no-repeat; + mask: url("data:image/svg+xml;charset=utf-8,") center / contain no-repeat; + filter: brightness(1.2); + transition: filter 0.2s ease; +} + +.toolbar-icon__icon--ai:hover::before { + filter: brightness(1.3); +} + + + +@keyframes aiTextAppear { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.sd-ai-text-appear { + display: inline; + opacity: 0; + animation: aiTextAppear .7s ease-out forwards; + animation-fill-mode: both; + will-change: opacity, transform; + /* Ensure each mark is treated as a separate animation context */ + contain: content; +} \ No newline at end of file diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue new file mode 100644 index 0000000000..771184c816 --- /dev/null +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -0,0 +1,413 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/super-editor/src/components/toolbar/ButtonGroup.vue b/packages/super-editor/src/components/toolbar/ButtonGroup.vue index 362e590d43..ba4429b6ea 100644 --- a/packages/super-editor/src/components/toolbar/ButtonGroup.vue +++ b/packages/super-editor/src/components/toolbar/ButtonGroup.vue @@ -104,6 +104,7 @@ const handleClickOutside = (e) => { class="toolbar-button toolbar-dropdown" @select="(key, option) => handleSelect(item, option)" @clickoutside="handleClickOutside" + :style="item.dropdownStyles.value" > } - The API response + */ +async function baseInsightsFetch(payload, options = {}) { + const apiKey = options.apiKey; + + // Use the provided endpoint from config, or fall back to the default + const apiEndpoint = options.endpoint || DEFAULT_API_ENDPOINT; + + try { + const headers = { + 'Content-Type': 'application/json', + }; + + // Only add the API key header if one is provided + if (apiKey) { + headers['x-api-key'] = apiKey; + } + + const response = await fetch(apiEndpoint, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Harbour API error: ${response.status} - ${errorText}`); + } + + return response; + } catch (error) { + console.error('Error calling Harbour API:', error); + throw error; + } +} + +/** + * UTILITY - Extracts content from a streaming response + * @param {ReadableStream} stream - The stream to process + * @param {function} onChunk - Callback for each text chunk + * @returns {Promise} - The complete generated text + */ +async function processStream(stream, onChunk, onDone) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ''; + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + if (typeof onDone === 'function') { + onDone(); + } + break; + } + + // Decode the chunk + const chunk = decoder.decode(value, { stream: true }); + + if (typeof onChunk === 'function') { + onChunk(chunk); + } + } + + // Final attempt to extract content from buffer + let extractedValue = getJsonBetweenFencesFromResponse(buffer); + if (extractedValue !== null) { + result = extractedValue; + } + + return result || ''; + } catch (error) { + console.error('Error reading stream:', error); + throw error; + } finally { + reader.releaseLock(); + } +} + +/** + * Helper function to extract content from buffer with markdown code fences + * @param {string} buffer - The text buffer to parse + * @returns {string|null} - The extracted content or null if not found + */ +function getJsonBetweenFencesFromResponse(buffer) { + try { + // Try to extract content between ```json and ``` + const jsonRegex = /```json\s*\n([\s\S]*?)\n\s*```/; + const match = buffer.match(jsonRegex); + + if (match && match[1]) { + const jsonObj = JSON.parse(match[1]); + + // Extract value from custom_prompt.value + if (jsonObj.custom_prompt && jsonObj.custom_prompt.value !== undefined) { + return jsonObj.custom_prompt.value || ''; + } + } + + return null; + } catch (e) { + return null; + } +} + +/** + * UTILITY - Extracts content from a non-streaming response + * @param {Response} response - The API response + * @returns {Promise} - The extracted content + */ +async function returnNonStreamingJson(response) { + const jsonResponse = await response.json(); + if (jsonResponse.custom_prompt) { + return jsonResponse.custom_prompt[0].value; + } else { + throw new Error('No custom prompt found in response'); + } +} + +/** + * Generate text based on a prompt with streaming + * @param {string} prompt - User prompt + * @param {Object} options - Additional options + * @param {string} options.context - System prompt to guide generation + * @param {string} options.documentXml - Document XML for context + * @param {string} options.url - URL of a document to analyze + * @param {Object} options.config - API configuration + * @param {function} onChunk - Callback for each text chunk + * @returns {Promise} - The complete generated text + */ +export async function writeStreaming(prompt, options = {}, onChunk, onDone) { + if (!prompt) { + throw new Error('Prompt is required for text generation'); + } + + const payload = { + stream: true, + context: SYSTEM_PROMPT, + doc_text: '', + insights: [ + { + type: 'custom_prompt', + name: 'text_generation', + message: `Generate text based on the following prompt: ${prompt}`, + }, + ], + }; + + // Add document content if available + if (options.documentXml) { + payload.document_content = options.documentXml; + } + + const response = await baseInsightsFetch(payload, options.config || {}); + + if (!response.body) return ''; + return await processStream(response.body, onChunk, onDone); +} + +/** + * Generate text based on a prompt (non-streaming) + * @param {string} prompt - User prompt + * @param {Object} options - Additional options + * @param {string} options.context - System prompt to guide generation + * @param {string} options.documentXml - Document XML for context + * @param {string} options.url - URL of a document to analyze + * @param {Object} options.config - API configuration + * @returns {Promise} - The generated text + */ +export async function write(prompt, options = {}) { + if (!prompt) { + throw new Error('Prompt is required for text generation'); + } + + const payload = { + stream: false, + context: SYSTEM_PROMPT, + insights: [ + { + type: 'custom_prompt', + name: 'text_generation', + message: `Generate text based on the following prompt: ${prompt}`, + format: [{ value: '' }], + }, + ], + }; + + const response = await baseInsightsFetch(payload, options.config || {}); + console.log('write response', response); + return returnNonStreamingJson(response); +} + +/** + * Rewrite text based on a prompt with streaming + * @param {string} text - Text to rewrite + * @param {string} prompt - User instructions for rewriting + * @param {Object} options - Additional options + * @param {string} options.documentXml - Document XML for context + * @param {string} options.url - URL of a document to analyze + * @param {Object} options.config - API configuration + * @param {function} onChunk - Callback for each text chunk + * @returns {Promise} - The complete rewritten text + */ +export async function rewriteStreaming(text, prompt = '', options = {}, onChunk, onDone) { + if (!text) { + throw new Error('Text is required for rewriting'); + } + + const message = prompt + ? `Rewrite the following text: "${text}". Instructions: ${prompt}` + : `Rewrite the following text: "${text}"`; + + const payload = { + stream: true, + context: SYSTEM_PROMPT, + insights: [ + { + type: 'custom_prompt', + name: 'text_rewrite', + message: `Rewrite the following text: "${text}" using these instructions: ${prompt}`, + }, + ], + }; + + const response = await baseInsightsFetch(payload, options.config || {}); + + if (!response.body) return ''; + + return await processStream(response.body, onChunk, onDone); +} + +/** + * Rewrite text based on a prompt (non-streaming) + * @param {string} text - Text to rewrite + * @param {string} prompt - User instructions for rewriting + * @param {Object} options - Additional options + * @param {string} options.documentXml - Document XML for context + * @param {string} options.url - URL of a document to analyze + * @param {Object} options.config - API configuration + * @returns {Promise} - The rewritten text + */ +export async function rewrite(text, prompt = '', options = {}) { + if (!text) { + throw new Error('Text is required for rewriting'); + } + + const message = prompt + ? `Rewrite the following text: "${text}". Instructions: ${prompt}` + : `Rewrite the following text: "${text}"`; + + const payload = { + stream: false, + context: SYSTEM_PROMPT, + insights: [ + { + type: 'custom_prompt', + name: 'text_rewrite', + message: `Rewrite the following text: "${text}" using these instructions: ${prompt}`, + format: [{ value: '' }], + }, + ], + }; + + const response = await baseInsightsFetch(payload, options.config || {}); + return returnNonStreamingJson(response); +} diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 09ef688585..6ef8fcec4e 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -4,6 +4,7 @@ import { h } from 'vue'; import { scrollToElement } from './scroll-helpers'; import { sanitizeNumber } from './helpers'; import { useToolbarItem } from './use-toolbar-item'; +import AIWriter from './AIWriter.vue'; import AlignmentButtons from './AlignmentButtons.vue'; import LinkInput from './LinkInput.vue'; import DocumentMode from './DocumentMode.vue'; @@ -84,6 +85,55 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, onDeactivate: () => (fontButton.label.value = fontButton.defaultLabel.value), }); + // ai button + const aiButton = useToolbarItem({ + type: 'dropdown', + dropdownStyles: { + padding: 0, + outline: 'none', + }, + name: 'ai', + tooltip: 'AI Insights', + icon: toolbarIcons.ai, + hideLabel: true, + hasCaret: false, + isWide: true, + suppressActiveHighlight: true, + options: [ + { + type: 'render', + key: 'ai', + render: () => { + let selectedText = ''; + + if (superToolbar.activeEditor) { + const { state } = superToolbar.activeEditor; + const { from, to, empty } = state.selection; + selectedText = !empty ? state.doc.textBetween(from, to) : ''; + } + + const handleClose = () => { + closeDropdown(aiButton); + }; + + return h( + 'div', + {}, + [ + h(AIWriter, { + handleClose, + selectedText, + editor: superToolbar.activeEditor, + key: superToolbar.config.aiApiKey, + superToolbar: superToolbar, + }), + ], + ); + }, + }, + ], + }); + // font size const fontSize = useToolbarItem({ type: 'dropdown', @@ -749,6 +799,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, ['textAlign', 40], ['linkedStyles', 142], ['documentMode', 47], + ['ai', 32], ['default', 32], ]); @@ -870,6 +921,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, separator, link, image, + aiButton, tableItem, tableActionsItem, separator, @@ -891,6 +943,10 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, // search, ]; + if (!superToolbar.config.aiApiKey) { + toolbarItems = toolbarItems.filter((item) => item.name.value !== 'ai'); + } + // Hide separators on small screens if (windowWidth <= breakpoints.md) { toolbarItems = toolbarItems.filter((item) => item.type !== 'separator'); diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 203f1ddafa..0ce31e715b 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -24,6 +24,7 @@ export class SuperToolbar extends EventEmitter { excludeItems: [], groups: null, editor: null, + aiApiKey: null, }; constructor(config) { diff --git a/packages/super-editor/src/components/toolbar/toolbarIcons.js b/packages/super-editor/src/components/toolbar/toolbarIcons.js index f0b8630023..45fb7c2acb 100644 --- a/packages/super-editor/src/components/toolbar/toolbarIcons.js +++ b/packages/super-editor/src/components/toolbar/toolbarIcons.js @@ -34,6 +34,7 @@ import caretDownIconSvg from '@harbour-enterprises/common/icons/caret-down-solid import rulerSvg from '@harbour-enterprises/common/icons/ruler-solid.svg?raw'; import paintbrushSvg from '@harbour-enterprises/common/icons/paintbrush-solid.svg?raw'; import highlighterIcon from '@harbour-enterprises/common/icons/highlighter-icon.svg?raw'; +import magicWandIcon from '@harbour-enterprises/common/icons/magic-wand-solid.svg?raw'; import tableIconSvg from '@harbour-enterprises/common/icons/table-solid.svg?raw'; import tableColumnsIconSvg from '@harbour-enterprises/common/icons/table-columns-solid.svg?raw'; import arrowsLeftRightIconSvg from '@harbour-enterprises/common/icons/arrows-left-right-solid.svg?raw'; @@ -84,6 +85,7 @@ export const toolbarIcons = { ruler: rulerSvg, paintbrush: paintbrushSvg, highlight: highlighterIcon, + ai: magicWandIcon, table: tableIconSvg, tableActions: tableColumnsIconSvg, splitCell: arrowsLeftRightIconSvg, diff --git a/packages/super-editor/src/components/toolbar/use-toolbar-item.js b/packages/super-editor/src/components/toolbar/use-toolbar-item.js index c2cac20a02..7eaea683f1 100644 --- a/packages/super-editor/src/components/toolbar/use-toolbar-item.js +++ b/packages/super-editor/src/components/toolbar/use-toolbar-item.js @@ -44,6 +44,9 @@ export const useToolbarItem = (options) => { const iconColor = ref(options.iconColor); const hasCaret = ref(options.hasCaret); + // dropdown properties + const dropdownStyles = ref(options.dropdownStyles); + // tooltip properties const tooltip = ref(options.tooltip); const tooltipVisible = ref(options.tooltipVisible); @@ -126,6 +129,7 @@ export const useToolbarItem = (options) => { parentItem, iconColor, hasCaret, + dropdownStyles, tooltipVisible, tooltipTimeout, defaultLabel, diff --git a/packages/super-editor/src/extensions/ai/ai-constants.js b/packages/super-editor/src/extensions/ai/ai-constants.js new file mode 100644 index 0000000000..b3acb4ee9d --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-constants.js @@ -0,0 +1,2 @@ +export const AiMarkName = 'aiMark'; +export const AiAnimationMarkName = 'aiAnimationMark'; diff --git a/packages/super-editor/src/extensions/ai/ai-marks.js b/packages/super-editor/src/extensions/ai/ai-marks.js new file mode 100644 index 0000000000..ed0826634c --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-marks.js @@ -0,0 +1,74 @@ +import { Mark, Attribute } from '@core/index.js'; +import { AiMarkName, AiAnimationMarkName } from './ai-constants.js'; + +export const AiMark = Mark.create({ + name: AiMarkName, + + group: 'ai', + + inclusive: false, + + addOptions() { + return { + htmlAttributes: { class: 'super-editor-ai' }, + }; + }, + + addAttributes() { + return { + id: { + default: null, + rendered: false, + } + }; + }, + + parseDOM() { + return [{ tag: AiMarkName }]; + }, + + renderDOM({ htmlAttributes }) { + return [AiMarkName, Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes)]; + }, +}); + +export const AiAnimationMark = Mark.create({ + name: AiAnimationMarkName, + + group: 'ai', + + inclusive: false, + spanning: false, + excludes: AiAnimationMarkName, + + addOptions() { + return { + htmlAttributes: {}, + }; + }, + + addAttributes() { + return { + id: { + default: null, + rendered: false, + }, + class: { + default: null, + rendered: true, + }, + 'dataMarkId': { + default: null, + rendered: true, + } + }; + }, + + parseDOM() { + return [{ tag: AiAnimationMarkName }]; + }, + + renderDOM({ htmlAttributes }) { + return [AiAnimationMarkName, Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes)]; + }, +}); diff --git a/packages/super-editor/src/extensions/ai/ai-plugin.js b/packages/super-editor/src/extensions/ai/ai-plugin.js new file mode 100644 index 0000000000..81d359e1a9 --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-plugin.js @@ -0,0 +1,126 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Extension } from '@core/Extension.js'; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { AiMarkName } from './ai-constants.js'; + +export const AiPluginKey = new PluginKey('ai'); + +export const AiPlugin = Extension.create({ + name: 'ai', + + addCommands() { + return { + insertAiMark: () => ({ tr, dispatch, state }) => { + const { selection } = tr; + const { $from, $to } = selection; + + // Only add mark if there's a selection + if ($from.pos === $to.pos) return false; + + tr.addMark( + $from.pos, + $to.pos, + this.editor.schema.marks[AiMarkName].create({ + id: 'ai-highlight' + }) + ); + + if (dispatch) dispatch(tr); + return true; + }, + + /** + * Remove all AI marks from the document + * @param {String} markName - The name of the mark to remove - defaults to AiMarkName + * Can also be used to remove the ai animation mark after streams are complete + * @returns {Boolean} - True if the mark was removed, false otherwise + */ + removeAiMark: (markName = AiMarkName) => ({ tr, dispatch, state }) => { + // Loop through the document to find and remove all AI marks + const { doc } = state; + let markFound = false; + + doc.descendants((node, pos) => { + const { marks = [] } = node; + const aiMark = marks.find((mark) => mark.type.name === markName); + + if (aiMark) { + markFound = true; + tr.removeMark(pos, pos + node.nodeSize, state.schema.marks[markName]); + } + }); + + if (markFound) { + if (dispatch) dispatch(tr); + return true; + } + + return false; + } + }; + }, + + addPmPlugins() { + const editor = this.editor; + const aiPlugin = new Plugin({ + key: AiPluginKey, + state: { + init() { + return { + decorations: DecorationSet.empty, + highlightColor: '#6366f1' // Indigo color, matches AiLayer + }; + }, + apply(tr, oldState, _, newEditorState) { + // If the document hasn't changed, return the old state + if (!tr.docChanged) return oldState; + + // Process AI highlights in the document + const { decorations } = processAiHighlights(editor, newEditorState.doc, oldState.highlightColor) || {}; + const decorationSet = DecorationSet.create(newEditorState.doc, decorations); + + return { + ...oldState, + decorations: decorationSet, + }; + } + }, + props: { + decorations(state) { + return this.getState(state).decorations; + } + } + }); + return [aiPlugin]; + }, +}); + +/** + * Iterate through the document to find AI marks and create decorations for them + * @param {*} editor The current editor instance + * @param {*} doc The current document + * @param {string} highlightColor The color to use for highlights + * @returns {Object} The decorations for AI marks + */ +const processAiHighlights = (editor, doc, highlightColor) => { + const decorations = []; + + doc.descendants((node, pos) => { + // Check if it contains the aiMarkName + const { marks = [] } = node; + const aiMark = marks.find((mark) => mark.type.name === AiMarkName); + + if (aiMark) { + const deco = Decoration.inline( + pos, + pos + node.nodeSize, + { + style: `background-color: ${highlightColor}33; border-radius: 4px; transition: background-color 250ms ease;`, // 33 is 20% opacity in hex + } + ); + decorations.push(deco); + } + }); + + return { decorations }; +}; diff --git a/packages/super-editor/src/extensions/ai/index.js b/packages/super-editor/src/extensions/ai/index.js new file mode 100644 index 0000000000..c9f9915683 --- /dev/null +++ b/packages/super-editor/src/extensions/ai/index.js @@ -0,0 +1,3 @@ +export * from './ai-constants.js'; +export * from './ai-marks.js'; +export * from './ai-plugin.js'; diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index fb88fb384a..b25b33ac98 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -11,6 +11,7 @@ import { DropCursor } from './dropcursor/index.js'; import { Gapcursor } from './gapcursor/index.js'; import { Collaboration } from './collaboration/index.js'; import { CollaborationCursor } from './collaboration-cursor/index.js'; +import { AiPlugin, AiMark, AiAnimationMark } from './ai/index.js'; // Nodes extensions import { Document } from './document/index.js'; @@ -49,8 +50,8 @@ import { CommentsMark } from './comment/index.js'; import { CommentsPlugin } from './comment/index.js'; import { Placeholder } from './placeholder/index.js'; import { PopoverPlugin } from './popover-plugin/index.js'; -import { TrackChanges } from "./track-changes/index.js"; -import { Pagination } from "./pagination/index.js"; +import { TrackChanges } from './track-changes/index.js'; +import { Pagination } from './pagination/index.js'; import { LinkedStyles } from './linked-styles/linked-styles.js'; // Helpers @@ -94,6 +95,7 @@ const getRichTextExtensions = () => { TrackInsert, TrackDelete, TrackFormat, + AiPlugin, Image, ]; }; @@ -149,6 +151,9 @@ const getStarterExtensions = () => { Pagination, Highlight, LinkedStyles, + AiPlugin, + AiMark, + AiAnimationMark, PageNumber, TotalPageCount, ]; @@ -207,4 +212,7 @@ export { trackChangesHelpers, getStarterExtensions, getRichTextExtensions, + AiMark, + AiAnimationMark, + AiPlugin, }; diff --git a/packages/super-editor/src/index.js b/packages/super-editor/src/index.js index ba59c240fa..c00907a5c6 100644 --- a/packages/super-editor/src/index.js +++ b/packages/super-editor/src/index.js @@ -16,6 +16,7 @@ import SuperEditor from './components/SuperEditor.vue'; import BasicUpload from './dev/components/BasicUpload.vue'; import Toolbar from './components/toolbar/Toolbar.vue'; import SuperInput from './components/SuperInput.vue'; +import AIWriter from './components/toolbar/AIWriter.vue'; import * as fieldAnnotationHelpers from './extensions/field-annotation/fieldAnnotationHelpers/index.js'; import * as trackChangesHelpers from './extensions/track-changes/trackChangesHelpers/index.js'; import { TrackChangesBasePluginKey } from './extensions/track-changes/plugins/index.js'; @@ -35,6 +36,7 @@ const Extensions = { * @see DocxZipper * @see SuperEditor * @see Toolbar + * @see AIWriter */ export { // Classes @@ -48,6 +50,7 @@ export { SuperInput, BasicUpload, Toolbar, + AIWriter, // Helpers helpers, diff --git a/packages/super-editor/vite.config.js b/packages/super-editor/vite.config.js index 8a01b45b1f..b289fd601f 100644 --- a/packages/super-editor/vite.config.js +++ b/packages/super-editor/vite.config.js @@ -48,6 +48,7 @@ export default defineConfig(({ mode }) => { 'docx-zipper': '@core/DocxZipper', 'toolbar': '@components/toolbar/Toolbar.vue', 'file-zipper': '@core/super-converter/zipper.js', + 'ai-writer': '@components/toolbar/AIWriter.vue', }, output: { globals: { @@ -61,6 +62,7 @@ export default defineConfig(({ mode }) => { 'toolbar': ['@components/toolbar/Toolbar.vue'], 'super-input': ['@components/SuperInput.vue'], 'file-zipper': ['@core/super-converter/zipper.js'], + 'ai-writer': ['@components/toolbar/AIWriter.vue'], }, entryFileNames: '[name].es.js', chunkFileNames: 'chunks/[name]-[hash].js' diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 65df4ac602..db5956420c 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -25,9 +25,12 @@ import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import { DOCX, PDF, HTML } from '@harbour-enterprises/common'; -import { SuperEditor } from '@harbour-enterprises/super-editor'; +import { SuperEditor, AIWriter } from '@harbour-enterprises/super-editor'; import HtmlViewer from './components/HtmlViewer/HtmlViewer.vue'; import useComment from './components/CommentsLayer/use-comment'; +import AiLayer from './components/AiLayer/AiLayer.vue'; +import { useSelectedText } from './composables/use-selected-text'; +import { useAi } from './composables/use-ai'; // Stores const superdocStore = useSuperdocStore(); @@ -73,6 +76,28 @@ const layers = ref(null); const commentsLayer = ref(null); const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 }); +// Create a ref to pass to the composable +const activeEditorRef = computed(() => proxy.$superdoc.activeEditor); + +// Use the composable to get the selected text +const { selectedText } = useSelectedText(activeEditorRef); + +// Use the AI composable +const { + showAiLayer, + showAiWriter, + aiWriterPosition, + aiLayer, + initAiLayer, + handleAiHighlight, + showAiWriterAtCursor, + handleAiWriterClose, + handleAiToolClick +} = useAi({ + emitAiHighlight: (params) => proxy.$superdoc.emit('ai-highlight', params), + activeEditorRef +}); + // Hrbr Fields const hrbrFieldsLayer = ref(null); @@ -92,6 +117,7 @@ const handleDocumentReady = (documentId, container) => { const handleToolClick = (tool) => { const toolOptions = { comments: () => showAddComment(proxy.$superdoc), + ai: () => handleAiToolClick(), }; if (tool in toolOptions) { @@ -137,6 +163,8 @@ const onEditorCreate = ({ editor }) => { proxy.$superdoc.broadcastEditorCreate(editor); proxy.$superdoc.log('[SuperDoc] Editor created', proxy.$superdoc.activeEditor); proxy.$superdoc.log('[SuperDoc] Page styles (pixels)', editor.getPageStyles()); + // Initialize the ai layer + initAiLayer(true); }; const onEditorDestroy = () => { @@ -340,10 +368,12 @@ onMounted(() => { if (isCommentsEnabled.value && !modules.comments.readOnly) { document.addEventListener('mousedown', handleDocumentMouseDown); } + proxy.$superdoc.on('ai-highlight', handleAiHighlight); }); onBeforeUnmount(() => { document.removeEventListener('mousedown', handleDocumentMouseDown); + proxy.$superdoc.off('ai-highlight', handleAiHighlight); }); const selectionLayer = ref(null); @@ -518,6 +548,15 @@ watch(getFloatingComments, () => { + + + + @@ -555,6 +594,9 @@ watch(getFloatingComments, () => { @highlight-click="handleHighlightClick" /> + + + @@ -606,6 +648,16 @@ watch(getFloatingComments, () => { /> + + + + + @@ -682,6 +734,7 @@ watch(getFloatingComments, () => { position: absolute; z-index: 3; display: flex; + flex-direction: column; gap: 6px; } @@ -739,4 +792,90 @@ watch(getFloatingComments, () => { position: relative; } } + +/* AI Writer styles */ +.ai-writer-container { + position: fixed; + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); +} + +/* Remove the AI Sidebar styles */ +/* .ai-sidebar-container { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + z-index: 50; +} */ + +/* Tools styles */ +.tools { + position: absolute; + z-index: 3; + display: flex; + gap: 6px; +} + +.tools .tool-icon { + font-size: 20px; + border-radius: 12px; + border: none; + outline: none; + background-color: #dbdbdb; + cursor: pointer; +} + +.tools-item { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 50px; + height: 50px; + background-color: rgba(219, 219, 219, 0.6); + border-radius: 12px; + cursor: pointer; +} + +.tools-item i { + cursor: pointer; +} + +.superdoc__tools-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.ai-tool > svg { + fill: transparent; +} + +.ai-tool::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + + z-index: 1; + background: linear-gradient( + 270deg, + rgba(218, 215, 118, 0.5) -20%, + rgba(191, 100, 100, 1) 30%, + rgba(77, 82, 217, 1) 60%, + rgb(255, 219, 102) 150% + ); + -webkit-mask: url("data:image/svg+xml;charset=utf-8,") center / contain no-repeat; + mask: url("data:image/svg+xml;charset=utf-8,") center / contain no-repeat; + filter: brightness(1.2); + transition: filter 0.2s ease; +} + +.ai-tool:hover::before { + filter: brightness(1.3); +} +/* Tools styles - end */ diff --git a/packages/superdoc/src/components/AiLayer/AiLayer.vue b/packages/superdoc/src/components/AiLayer/AiLayer.vue new file mode 100644 index 0000000000..e2b13941c3 --- /dev/null +++ b/packages/superdoc/src/components/AiLayer/AiLayer.vue @@ -0,0 +1,87 @@ + + + + + + + + + + diff --git a/packages/superdoc/src/composables/use-ai.js b/packages/superdoc/src/composables/use-ai.js new file mode 100644 index 0000000000..b408734798 --- /dev/null +++ b/packages/superdoc/src/composables/use-ai.js @@ -0,0 +1,138 @@ +import { ref, reactive } from 'vue'; + +/** + * Composable to manage AI layer and AI writer functionality + * + * @param {Object} options - Configuration options + * @param {Function} options.emitAiHighlight - Function to emit AI highlight events + * @param {Object} options.activeEditorRef - Ref to the active editor + * @returns {Object} - AI state and methods + */ +export function useAi({ emitAiHighlight, activeEditorRef }) { + // Shared state + const showAiLayer = ref(false); + const showAiWriter = ref(false); + const aiWriterPosition = reactive({ top: 0, left: 0 }); + const aiLayer = ref(null); + + /** + * Handle AI highlighting events + * + * @param {Object} params - Event parameters + * @param {String} params.type - Type of event (add/remove) + * @param {Object} params.data - Additional data (optional) + */ + const handleAiHighlight = ({ type, data }) => { + if (!aiLayer.value) { + console.error('[useAi] aiLayer.value is not available'); + return; + } + + if (type === 'add') { + aiLayer.value.addAiHighlight(); + } else if (type === 'remove') { + aiLayer.value.removeAiHighlight(); + } + }; + + /** + * Show the AI writer at the current cursor position + */ + const showAiWriterAtCursor = () => { + const editor = activeEditorRef.value; + if (!editor || editor.isDestroyed) { + console.error('[useAi] Editor not available'); + return; + } + + try { + // Get the current cursor position + const { view } = editor; + const { selection } = view.state; + + // If we have selected text, add AI highlighting + if (!selection.empty) { + // Emit the highlight event to trigger the AI highlighting + emitAiHighlight({ type: 'add', data: null }); + } + + let coords; + try { + // Try to get coordinates from the selection head + coords = view.coordsAtPos(selection.$head.pos); + } catch (e) { + // Fallback to using the DOM selection if ProseMirror position is invalid + const domSelection = window.getSelection(); + if (domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + coords = { top: rect.top, left: rect.left }; + } else { + // If no selection, use editor position + const editorRect = view.dom.getBoundingClientRect(); + coords = { top: editorRect.top + 50, left: editorRect.left + 50 }; + } + } + + // Position the AIWriter at the cursor position + // Move down 30px to render under the cursor + aiWriterPosition.top = coords.top + 30 + 'px'; + aiWriterPosition.left = coords.left + 'px'; + + // Show the AIWriter + showAiWriter.value = true; + } catch (error) { + console.error('[useAi] Error displaying AIWriter:', error); + // Fallback position in center of editor + try { + const editorDom = activeEditorRef.value.view.dom; + const rect = editorDom.getBoundingClientRect(); + aiWriterPosition.top = rect.top + 100 + 'px'; + aiWriterPosition.left = rect.left + 100 + 'px'; + showAiWriter.value = true; + } catch (e) { + console.error('[useAi] Failed to get fallback position:', e); + } + } + }; + + /** + * Handle closing the AI writer + */ + const handleAiWriterClose = () => { + showAiWriter.value = false; + // Remove the AI highlight when AIWriter is closed + emitAiHighlight({ type: 'remove', data: null }); + }; + + /** + * Initialize the AI layer + * + * @param {Boolean} value - Whether to show the AI layer + */ + const initAiLayer = (value = true) => { + showAiLayer.value = value; + }; + + /** + * Handle tool click for AI functionality + */ + const handleAiToolClick = () => { + showAiWriterAtCursor(); + }; + + return { + // State + showAiLayer, + showAiWriter, + aiWriterPosition, + aiLayer, + + // Methods + initAiLayer, + handleAiHighlight, + showAiWriterAtCursor, + handleAiWriterClose, + handleAiToolClick + }; +} \ No newline at end of file diff --git a/packages/superdoc/src/composables/use-selected-text.js b/packages/superdoc/src/composables/use-selected-text.js new file mode 100644 index 0000000000..2af4862ef7 --- /dev/null +++ b/packages/superdoc/src/composables/use-selected-text.js @@ -0,0 +1,25 @@ +import { computed } from 'vue'; + +/** + * Composable to get the currently selected text from an editor + * + * @param {Object} editorRef - Ref to the editor instance + * @returns {Object} - Object containing the selected text as a computed property + */ +export function useSelectedText(editorRef) { + // Create a computed property that will update when the editor selection changes + const selectedText = computed(() => { + const editor = editorRef.value; + if (!editor || !editor.state) return ''; + + return editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + ' ' + ); + }); + + return { + selectedText + }; +} \ No newline at end of file diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index fdb5196267..2612037618 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -33,7 +33,6 @@ import { * @property {string} [endpoint] The endpoint for telemetry */ - /** * @typedef {Object} Document * @property {string} id The ID of the document @@ -41,6 +40,14 @@ import { * @property {File | null} [data] The initial data of the document */ +/** + * @typedef {Object} Modules + * @property {Object} [comments] Comments module configuration + * @property {Object} [ai] AI module configuration + * @property {string} [ai.apiKey] Harbour API key for AI features + * @property {string} [ai.endpoint] Custom endpoint URL for AI services + */ + /** * @typedef {Object} Config * @property {string} [superdocId] The ID of the SuperDoc @@ -52,7 +59,7 @@ import { * @property {User} [user] The current user of this SuperDoc * @property {Array} [users] All users of this SuperDoc (can be used for "@"-mentions) * @property {Array} [colors] Colors to use for user awareness - * @property {Object} [modules] Modules to load + * @property {Modules} [modules] Modules to load * @property {boolean} [pagination] Whether to show pagination in SuperEditors * @property {string} [toolbar] Optional DOM element to render the toolbar in * @property {Array} [toolbarGroups] Toolbar groups to show @@ -103,7 +110,8 @@ export class SuperDoc extends EventEmitter { user: { name: null, email: null }, users: [], - modules: {}, // Optional: Modules to load + modules: {}, // Optional: Modules to load. Use modules.ai.{your_key} to pass in your key + title: 'SuperDoc', conversations: [], pagination: false, // Optional: Whether to show pagination in SuperEditors @@ -392,11 +400,17 @@ export class SuperDoc extends EventEmitter { icons: this.config.toolbarIcons, documentMode: this.config.documentMode, superdoc: this, + aiApiKey: this.config.modules?.ai?.apiKey, ...moduleConfig, }; this.toolbar = new SuperToolbar(config); + this.toolbar.on('superdoc-command', this.onToolbarCommand.bind(this)); + // AI highlight is not related to document editing, should be separate events + this.toolbar.on('ai-highlight', ({ type, data }) => { + this.emit('ai-highlight', { type, data }); + }); this.once('editorCreate', () => this.toolbar.updateToolbarState()); } diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 7dc8eeeb18..e6fc24bdbd 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -99,6 +99,12 @@ const init = async () => { // url: 'ws://localhost:3050/docs/superdoc-id', // token: 'token', // } + ai: { + // Provide your Harbour API key here for direct endpoint access + apiKey: '', + // Optional: Provide a custom endpoint for AI services + endpoint: 'https://sd-dev-express-gateway-i6xtm.ondigitalocean.app/insights', + }, }, onEditorCreate, onContentError, diff --git a/shared/common/icons/edit-regular.svg b/shared/common/icons/edit-regular.svg new file mode 100644 index 0000000000..fe0bc2df99 --- /dev/null +++ b/shared/common/icons/edit-regular.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/shared/common/icons/magic-wand-solid.svg b/shared/common/icons/magic-wand-solid.svg new file mode 100644 index 0000000000..140d2fa6d5 --- /dev/null +++ b/shared/common/icons/magic-wand-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/common/icons/paper-plane-regular.svg b/shared/common/icons/paper-plane-regular.svg new file mode 100644 index 0000000000..bfc060f85c --- /dev/null +++ b/shared/common/icons/paper-plane-regular.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/shared/common/icons/sun-regular.svg b/shared/common/icons/sun-regular.svg new file mode 100644 index 0000000000..27ac44bdc8 --- /dev/null +++ b/shared/common/icons/sun-regular.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/shared/common/icons/times-circle-regular.svg b/shared/common/icons/times-circle-regular.svg new file mode 100644 index 0000000000..bdc56a2337 --- /dev/null +++ b/shared/common/icons/times-circle-regular.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file