From 13aba6a46981108ad6df3a6173c5524c04006900 Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Tue, 25 Mar 2025 23:13:38 -0700 Subject: [PATCH 01/30] feat: initial commit for ai implementation --- packages/super-editor/index.html | 2 + .../src/assets/styles/elements/_all.css | 1 + .../assets/styles/elements/toolbar-custom.css | 36 ++ .../src/components/toolbar/AIWriter.vue | 421 ++++++++++++++++++ .../src/components/toolbar/ButtonGroup.vue | 1 + .../src/components/toolbar/ai-helpers.js | 302 +++++++++++++ .../src/components/toolbar/defaultItems.js | 55 +++ .../src/components/toolbar/super-toolbar.js | 9 +- .../src/components/toolbar/toolbarIcons.js | 2 + .../components/toolbar/use-toolbar-item.js | 4 + .../src/extensions/ai/ai-constants.js | 1 + .../src/extensions/ai/ai-marks.js | 33 ++ .../src/extensions/ai/ai-plugin.js | 121 +++++ .../super-editor/src/extensions/ai/index.js | 3 + packages/super-editor/src/extensions/index.js | 10 +- packages/superdoc/index.html | 2 + packages/superdoc/src/SuperDoc.vue | 29 +- .../src/components/AiLayer/AiLayer.vue | 94 ++++ packages/superdoc/src/core/SuperDoc.js | 9 +- .../src/dev/components/SuperdocDev.vue | 4 + shared/common/icons/magic-wand-solid.svg | 1 + 21 files changed, 1130 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/assets/styles/elements/toolbar-custom.css create mode 100644 packages/super-editor/src/components/toolbar/AIWriter.vue create mode 100644 packages/super-editor/src/components/toolbar/ai-helpers.js create mode 100644 packages/super-editor/src/extensions/ai/ai-constants.js create mode 100644 packages/super-editor/src/extensions/ai/ai-marks.js create mode 100644 packages/super-editor/src/extensions/ai/ai-plugin.js create mode 100644 packages/super-editor/src/extensions/ai/index.js create mode 100644 packages/superdoc/src/components/AiLayer/AiLayer.vue create mode 100644 shared/common/icons/magic-wand-solid.svg diff --git a/packages/super-editor/index.html b/packages/super-editor/index.html index f17fc7505e..3ac30eed64 100644 --- a/packages/super-editor/index.html +++ b/packages/super-editor/index.html @@ -4,6 +4,8 @@ Super Editor - Dev mode + +
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..4afddab519 --- /dev/null +++ b/packages/super-editor/src/assets/styles/elements/toolbar-custom.css @@ -0,0 +1,36 @@ +/* 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); +} \ 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..f568440c7f --- /dev/null +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/packages/super-editor/src/components/toolbar/ButtonGroup.vue b/packages/super-editor/src/components/toolbar/ButtonGroup.vue index e1c3edec56..7eecac4ab1 100644 --- a/packages/super-editor/src/components/toolbar/ButtonGroup.vue +++ b/packages/super-editor/src/components/toolbar/ButtonGroup.vue @@ -103,6 +103,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; + + // If an apiKey is provided, use the standard endpoint, otherwise use the gateway + const apiEndpoint = apiKey ? API_ENDPOINT : GATEWAY_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) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ''; + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + // Decode the chunk + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + // Try to extract content between ```json and ``` + let extractedValue = getJsonBetweenFencesFromResponse(buffer); + + if (extractedValue !== null) { + result = extractedValue; + if (typeof onChunk === 'function') { + onChunk(result); + } + } + } + + // 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) { + 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); +} + +/** + * 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) { + 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); +} + +/** + * 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 ffecd35d9a..ecabc1caf2 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'; @@ -82,6 +83,58 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, onDeactivate: () => (fontButton.label.value = fontButton.defaultLabel.value), }); + // ai button + const aiButton = useToolbarItem({ + type: 'dropdown', + dropdownStyles: { + boxShadow: '0 0 2px 2px #7715b366', + border: '1px solid #7715b3', + 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', + { + style: { + padding: '5px', + }, + }, + [ + h(AIWriter, { + handleClose, + selectedText, + superToolbar, + }), + ], + ); + }, + }, + ], + }); + // font size const fontSize = useToolbarItem({ type: 'dropdown', @@ -747,6 +800,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, ['textAlign', 40], ['linkedStyles', 142], ['documentMode', 47], + ['ai', 32], ['default', 32], ]); @@ -840,6 +894,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, separator, link, image, + aiButton, tableItem, tableActionsItem, separator, diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index ddb6934354..1351d45d1f 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -37,12 +37,11 @@ export class SuperToolbar extends EventEmitter { // 768px breakpoint doesn't consider iPad in portrait orientation const isSmallScreen = window.matchMedia('(max-width: 834px)').matches; - // Zoom property doesn't work correctly when testing on mobile devices if (isMobileDevice && isSmallScreen) { - layers.style.transformOrigin = '0 0'; + layers.style.transformOrigin = 'top left'; layers.style.transform = `scale(${parseInt(argument) / 100})`; } else { - layers.style.zoom = parseInt(argument) / 100; + layers.style.zoom = argument; } this.superdoc.superdocStore.activeZoom = parseInt(argument); @@ -202,6 +201,7 @@ export class SuperToolbar extends EventEmitter { this.isDev = config.isDev || false; this.superdoc = config.superdoc; this.role = config.role || 'editor'; + this.aiApiKey = config.aiApiKey; this.config.icons = { ...toolbarIcons, @@ -232,9 +232,10 @@ export class SuperToolbar extends EventEmitter { } setZoom(percent_int) { + const percent = percent_int / 100; const allItems = [...this.toolbarItems, ...this.overflowItems]; const item = allItems.find((item) => item.name.value === 'zoom'); - this.#interceptedCommands.setZoom({ item, argument: percent_int }); + this.#interceptedCommands.setZoom({ item, argument: percent }); } /** diff --git a/packages/super-editor/src/components/toolbar/toolbarIcons.js b/packages/super-editor/src/components/toolbar/toolbarIcons.js index 78634d4be4..7999343abd 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'; @@ -83,6 +84,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 42c1376637..dc052ebf9e 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); @@ -122,6 +125,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..c4bfadaf73 --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-constants.js @@ -0,0 +1 @@ +export const AiMarkName = 'aiMark'; \ No newline at end of file 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..2e495462aa --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-marks.js @@ -0,0 +1,33 @@ +import { Mark, Attribute } from '@core/index.js'; +import { AiMarkName } 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)]; + }, +}); \ No newline at end of file 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..77bc3688a5 --- /dev/null +++ b/packages/super-editor/src/extensions/ai/ai-plugin.js @@ -0,0 +1,121 @@ +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' + }) + ); + + dispatch(tr); + return true; + }, + + removeAiMark: () => ({ 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 === AiMarkName); + + if (aiMark) { + markFound = true; + tr.removeMark(pos, pos + node.nodeSize, state.schema.marks[AiMarkName]); + } + }); + + if (markFound) { + 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;`, // 33 is 20% opacity in hex + class: 'ai-highlight', + } + ); + decorations.push(deco); + } + }); + + return { decorations }; +}; \ No newline at end of file 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..77a8efa238 --- /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'; \ No newline at end of file diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 2e494114c7..504344befb 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 } from './ai/index.js'; // Nodes extensions import { Document } from './document/index.js'; @@ -48,8 +49,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 @@ -71,6 +72,7 @@ const getRichTextExtensions = () => [ PopoverPlugin, Mention, Highlight, + AiPlugin, ]; const getStarterExtensions = () => { @@ -124,6 +126,8 @@ const getStarterExtensions = () => { Pagination, Highlight, LinkedStyles, + AiPlugin, + AiMark, ]; }; @@ -180,4 +184,6 @@ export { trackChangesHelpers, getStarterExtensions, getRichTextExtensions, + AiMark, + AiPlugin, }; diff --git a/packages/superdoc/index.html b/packages/superdoc/index.html index 6bbd2e16f0..2d12ddedf8 100644 --- a/packages/superdoc/index.html +++ b/packages/superdoc/index.html @@ -4,6 +4,8 @@ SuperDoc + +
diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index b3e693953d..92b49d6041 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -28,6 +28,7 @@ import { DOCX, PDF, HTML } from '@harbour-enterprises/common'; import { SuperEditor } 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'; // Stores const superdocStore = useSuperdocStore(); @@ -68,6 +69,7 @@ commentsStore.proxy = proxy; // Refs const layers = ref(null); +const isCollaborationReady = ref(false); // Comments layer const commentsLayer = ref(null); @@ -84,8 +86,6 @@ const handleDocumentReady = (documentId, container) => { if (!proxy.$superdoc.config.collaboration) isReady.value = true; nextTick(() => initialCheck()); } - - isFloatingCommentsReady.value = true; proxy.$superdoc.broadcastPdfDocumentReady(); }; @@ -332,10 +332,14 @@ onMounted(() => { if (isCommentsEnabled.value && !modules.comments.readOnly) { document.addEventListener('mousedown', handleDocumentMouseDown); } + proxy.$superdoc.on('ai-highlight-add', handleAiHighlightAdd); + proxy.$superdoc.on('ai-highlight-remove', handleAiHighlightRemove); }); onBeforeUnmount(() => { document.removeEventListener('mousedown', handleDocumentMouseDown); + proxy.$superdoc.off('ai-highlight-add', handleAiHighlightAdd); + proxy.$superdoc.off('ai-highlight-remove', handleAiHighlightRemove); }); const selectionLayer = ref(null); @@ -493,6 +497,24 @@ const handlePdfClick = (e) => { handleSelectionStart(e); }; +const aiLayer = ref(null); + +const handleAiHighlightAdd = () => { + if (!aiLayer.value) { + console.error('[Superdoc] aiLayer.value is not available'); + return; + } + aiLayer.value.addAiHighlight(); +}; + +const handleAiHighlightRemove = () => { + if (!aiLayer.value) { + console.error('[Superdoc] aiLayer.value is not available'); + return; + } + aiLayer.value.removeAiHighlight(); +}; + watch(getFloatingComments, () => { hasInitializedLocations.value = false; nextTick(() => { @@ -547,6 +569,9 @@ watch(getFloatingComments, () => { @highlight-click="handleHighlightClick" /> + + +
diff --git a/packages/superdoc/src/components/AiLayer/AiLayer.vue b/packages/superdoc/src/components/AiLayer/AiLayer.vue new file mode 100644 index 0000000000..84d6fa1f1c --- /dev/null +++ b/packages/superdoc/src/components/AiLayer/AiLayer.vue @@ -0,0 +1,94 @@ + + + + + + + \ No newline at end of file diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 7d014226fe..218c05770e 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 @@ -98,7 +97,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 @@ -356,10 +356,15 @@ export class SuperDoc extends EventEmitter { icons: this.config.toolbarIcons, documentMode: this.config.documentMode, superdoc: this, + aiApiKey: this.config.modules?.ai?.apiKey, }; 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-add', (data) => this.emit('ai-highlight-add', data)); + this.toolbar.on('ai-highlight-remove', () => this.emit('ai-highlight-remove')); 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 45e779df99..3168b7b837 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -83,6 +83,10 @@ const init = async () => { // url: 'ws://localhost:3050/docs/superdoc-id', // token: 'token', // } + ai: { + // Provide your Harbour API key here for direct endpoint access + apiKey: '', + }, }, onEditorCreate, onContentError, 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 From 9ab0fa42b3f5c1f7f5b2c66cab0c96f240ec786c Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 26 Mar 2025 21:29:04 -0700 Subject: [PATCH 02/30] fix: add ai icons to commons --- packages/super-editor/index.html | 2 - .../src/components/toolbar/AIWriter.vue | 85 ++++++++++++++----- .../src/components/toolbar/ai-helpers.js | 55 ++++++------ packages/superdoc/index.html | 2 - shared/common/icons/edit-regular.svg | 1 + shared/common/icons/paper-plane-regular.svg | 1 + shared/common/icons/sun-regular.svg | 1 + shared/common/icons/times-circle-regular.svg | 1 + 8 files changed, 94 insertions(+), 54 deletions(-) create mode 100644 shared/common/icons/edit-regular.svg create mode 100644 shared/common/icons/paper-plane-regular.svg create mode 100644 shared/common/icons/sun-regular.svg create mode 100644 shared/common/icons/times-circle-regular.svg diff --git a/packages/super-editor/index.html b/packages/super-editor/index.html index 3ac30eed64..f17fc7505e 100644 --- a/packages/super-editor/index.html +++ b/packages/super-editor/index.html @@ -4,8 +4,6 @@ Super Editor - Dev mode - -
diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index f568440c7f..59b1d11a8a 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -52,7 +52,7 @@ onMounted(() => { // Add click outside listener document.addEventListener('mousedown', handleClickOutside); - + // Add a capture phase event listener directly to the document // We have to intercept the arrow keys to prevent them from being intercepted by ProseMirror document.addEventListener('keydown', handleCaptureKeyDown, true); @@ -69,8 +69,11 @@ onUnmounted(() => { // Capture phase handler to stop arrow key events from being intercepted in our ai textarea const handleCaptureKeyDown = (event) => { - if (editableRef.value && (event.target === editableRef.value) && - ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + if ( + editableRef.value && + event.target === editableRef.value && + ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) { event.stopPropagation(); // This prevents ProseMirror from seeing the event } }; @@ -111,19 +114,19 @@ const handleTextChunk = (text) => { props.superToolbar.emit('ai-highlight-remove'); textProcessingStarted.value = true; } - + // If the text is null, undefined or empty, don't process it if (text === null || text === undefined) { return; } - + // Convert to string in case it's not already a string const textStr = String(text || ''); - + // Handle incremental updates with plaintext // Only insert content that hasn't been inserted yet let newContent = ''; - + if (previousText.value.length === 0) { // First chunk - insert everything newContent = textStr; @@ -137,7 +140,7 @@ const handleTextChunk = (text) => { newContent = textStr; } } - + // Update the document with only the new content if (newContent) { props.superToolbar.activeEditor.commands.insertContent(newContent); @@ -177,7 +180,7 @@ const handleSubmit = async () => { config: { // Pass the aiApiKey from superToolbar to the AI helper functions apiKey: props.superToolbar.aiApiKey, - } + }, }; // @DEBUG - Use non-streaming for now @@ -196,11 +199,11 @@ const handleSubmit = async () => { } else { // NON-STREAMING APPROACH let generatedText; - + if (props.selectedText) { // Get rewritten text generatedText = await rewrite(props.selectedText, promptText.value, options); - + // Remove the selected text props.superToolbar.activeEditor.commands.deleteSelection(); // Remove the ai highlight @@ -209,7 +212,7 @@ const handleSubmit = async () => { // Get generated text generatedText = await write(promptText.value, options); } - + // Insert the generated text if (generatedText) { props.superToolbar.activeEditor.commands.insertContent(generatedText); @@ -253,8 +256,8 @@ const handleInput = (event) => { @@ -290,17 +295,51 @@ const handleInput = (event) => { position: relative; } -.fa-gradient { - background: linear-gradient( +.paper-plane { + --webkit-mask-image: url('@harbour-enterprises/common/icons/paper-plane-regular.svg'); + --mask-image: url('@harbour-enterprises/common/icons/paper-plane-regular.svg'); +} + +.edit { + --webkit-mask-image: url('@harbour-enterprises/common/icons/edit-regular.svg'); + --mask-image: url('@harbour-enterprises/common/icons/edit-regular.svg'); +} + +.times-circle { + --webkit-mask-image: url('@harbour-enterprises/common/icons/times-circle-regular.svg'); + --mask-image: url('@harbour-enterprises/common/icons/times-circle-regular.svg'); + background: #ed4337 !important; +} + +.sun { + --webkit-mask-image: url('@harbour-enterprises/common/icons/sun-regular.svg'); + --mask-image: url('@harbour-enterprises/common/icons/sun-regular.svg'); +} + +.gradient-svg { + /* Give your container some size */ + width: 16px; + height: 16px; + + /* Apply a gradient background */ + 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% ); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + + /* Use the SVG as a mask */ + -webkit-mask-image: var(--webkit-mask-image); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + + mask-image: var(--mask-image); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; } .ai-writer { diff --git a/packages/super-editor/src/components/toolbar/ai-helpers.js b/packages/super-editor/src/components/toolbar/ai-helpers.js index 052cd5d631..82d5e78298 100644 --- a/packages/super-editor/src/components/toolbar/ai-helpers.js +++ b/packages/super-editor/src/components/toolbar/ai-helpers.js @@ -1,11 +1,11 @@ /** * AI Helpers - Utilities for interacting with Harbour API for document insights * Based on documentation at: https://harbour-enterprises.github.io/Harbour-API-Docs/#insights - * + * * Endpoint Selection Logic: * - If an API key is provided, the standard Harbour API endpoint is used * - If no API key is provided, requests are routed through the SuperDoc gateway - * + * * The API key can be configured when instantiating SuperDoc: * ``` * const config = { @@ -23,7 +23,8 @@ // should be used based on that const API_ENDPOINT = 'https://api.myharbourshare.com/v2/insights'; const GATEWAY_ENDPOINT = 'https://sd-dev-express-gateway-i6xtm.ondigitalocean.app/insights'; -const SYSTEM_PROMPT = 'You are an expert copywriter and you are immersed in a document editor. You are to provide document related text responses based on the user prompts. Only write what is asked for. Do not provide explanations. Try to keep placeholders as short as possible. Do not output your prompt. Your instructions are: '; +const SYSTEM_PROMPT = + 'You are an expert copywriter and you are immersed in a document editor. You are to provide document related text responses based on the user prompts. Only write what is asked for. Do not provide explanations. Try to keep placeholders as short as possible. Do not output your prompt. Your instructions are: '; /** * UTILITY - Makes a fetch request to the Harbour API * @param {Object} payload - The request payload @@ -34,15 +35,15 @@ const SYSTEM_PROMPT = 'You are an expert copywriter and you are immersed in a do */ async function baseInsightsFetch(payload, options = {}) { const apiKey = options.apiKey; - + // If an apiKey is provided, use the standard endpoint, otherwise use the gateway - const apiEndpoint = apiKey ? API_ENDPOINT : GATEWAY_ENDPOINT + const apiEndpoint = apiKey ? API_ENDPOINT : GATEWAY_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; @@ -87,10 +88,10 @@ async function processStream(stream, onChunk) { // Decode the chunk const chunk = decoder.decode(value, { stream: true }); buffer += chunk; - + // Try to extract content between ```json and ``` let extractedValue = getJsonBetweenFencesFromResponse(buffer); - + if (extractedValue !== null) { result = extractedValue; if (typeof onChunk === 'function') { @@ -98,13 +99,13 @@ async function processStream(stream, onChunk) { } } } - + // 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); @@ -124,16 +125,16 @@ function getJsonBetweenFencesFromResponse(buffer) { // 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; @@ -172,14 +173,14 @@ export async function writeStreaming(prompt, options = {}, onChunk) { const payload = { stream: true, context: SYSTEM_PROMPT, - doc_text:'', + doc_text: '', insights: [ { type: 'custom_prompt', name: 'text_generation', message: `Generate text based on the following prompt: ${prompt}`, - } - ] + }, + ], }; // Add document content if available @@ -188,7 +189,7 @@ export async function writeStreaming(prompt, options = {}, onChunk) { } const response = await baseInsightsFetch(payload, options.config || {}); - + if (!response.body) return ''; return await processStream(response.body, onChunk); } @@ -216,9 +217,9 @@ export async function write(prompt, options = {}) { type: 'custom_prompt', name: 'text_generation', message: `Generate text based on the following prompt: ${prompt}`, - format: [{ value: '' }] - } - ] + format: [{ value: '' }], + }, + ], }; const response = await baseInsightsFetch(payload, options.config || {}); @@ -254,14 +255,14 @@ export async function rewriteStreaming(text, prompt = '', options = {}, onChunk) 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); } @@ -292,9 +293,9 @@ export async function rewrite(text, prompt = '', options = {}) { type: 'custom_prompt', name: 'text_rewrite', message: `Rewrite the following text: "${text}" using these instructions: ${prompt}`, - format: [{ value: '' }] - } - ] + format: [{ value: '' }], + }, + ], }; const response = await baseInsightsFetch(payload, options.config || {}); diff --git a/packages/superdoc/index.html b/packages/superdoc/index.html index 2d12ddedf8..6bbd2e16f0 100644 --- a/packages/superdoc/index.html +++ b/packages/superdoc/index.html @@ -4,8 +4,6 @@ SuperDoc - -
diff --git a/shared/common/icons/edit-regular.svg b/shared/common/icons/edit-regular.svg new file mode 100644 index 0000000000..1583d621af --- /dev/null +++ b/shared/common/icons/edit-regular.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..bd6596c541 --- /dev/null +++ b/shared/common/icons/paper-plane-regular.svg @@ -0,0 +1 @@ + \ 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..db47d2ded3 --- /dev/null +++ b/shared/common/icons/sun-regular.svg @@ -0,0 +1 @@ + \ 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..4c3b4bd201 --- /dev/null +++ b/shared/common/icons/times-circle-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file From a699dc125e94c0064eb00e1445b2978e1ac6bd66 Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 26 Mar 2025 23:01:42 -0700 Subject: [PATCH 03/30] fix: self contain the ai writer styles --- packages/super-editor/package.json | 3 +++ .../src/components/toolbar/AIWriter.vue | 13 +++++++------ .../src/components/toolbar/defaultItems.js | 9 ++------- packages/super-editor/src/index.js | 3 +++ packages/super-editor/vite.config.js | 2 ++ 5 files changed, 17 insertions(+), 13 deletions(-) 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/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index 59b1d11a8a..1d5c3234d2 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -268,7 +268,7 @@ const handleInput = (event) => { @keydown="handleKeyDown" @input="handleInput" v-model="promptText" - rows="2" + rows="4" >
@@ -277,7 +277,7 @@ const handleInput = (event) => { - @@ -346,11 +346,16 @@ const handleInput = (event) => { display: flex; flex-direction: column; width: 300px; + border-radius: 5px; overflow-y: scroll; /* Firefox */ scrollbar-width: none; /* Internet Explorer and Edge */ -ms-overflow-style: none; + + padding: 0.75rem; + box-shadow: 0 0 2px 2px #7715b366; + border: 1px solid #7715b3; } /* Chrome, Safari, and Opera */ @@ -384,8 +389,6 @@ const handleInput = (event) => { display: flex; flex-direction: row; min-height: 50px; - height: 50px; - padding: 10px; resize: none; border: none; border-radius: 8px; @@ -454,7 +457,5 @@ const handleInput = (event) => { height: 14px; justify-content: flex-end; align-items: center; - padding-right: 5px; - padding-left: 5px; } diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index ecabc1caf2..48c0bc4635 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -87,8 +87,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, const aiButton = useToolbarItem({ type: 'dropdown', dropdownStyles: { - boxShadow: '0 0 2px 2px #7715b366', - border: '1px solid #7715b3', + padding: 0, outline: 'none', }, name: 'ai', @@ -117,11 +116,7 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, return h( 'div', - { - style: { - padding: '5px', - }, - }, + {}, [ h(AIWriter, { handleClose, diff --git a/packages/super-editor/src/index.js b/packages/super-editor/src/index.js index d9e0907dfd..b618caf004 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'; @@ -33,6 +34,7 @@ const Extensions = { * @see DocxZipper * @see SuperEditor * @see Toolbar + * @see AIWriter */ export { // Classes @@ -46,6 +48,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 6022b13f5f..1a4e44c4da 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' From e672dbd9d19391154dd2269bc55ad9828507147f Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 26 Mar 2025 23:36:15 -0700 Subject: [PATCH 04/30] refactor: no need to pass the whole toolbar to ai writer --- .../src/components/toolbar/AIWriter.vue | 37 ++++++++++--------- .../src/components/toolbar/defaultItems.js | 3 +- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index 1d5c3234d2..f6ceca4851 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -11,10 +11,13 @@ const props = defineProps({ type: Function, required: true, }, - superToolbar: { + editor: { type: Object, required: true, }, + key: { + type: String, + } }); // Store the selection state @@ -35,12 +38,12 @@ const editableRef = ref(null); // Save selection when component is mounted onMounted(() => { if (props.selectedText) { - selectionState.value = props.superToolbar.activeEditor.state.selection; + selectionState.value = props.editor.state.selection; // Store the selection in the editor's state - props.superToolbar.activeEditor.commands.setMeta('storedSelection', selectionState.value); + props.editor.commands.setMeta('storedSelection', selectionState.value); // Emit ai highlight when the writer mounts - props.superToolbar.emit('ai-highlight-add'); + props.editor.emit('ai-highlight-add'); } // Focus the textarea on mount @@ -60,7 +63,7 @@ onMounted(() => { onUnmounted(() => { // emit the ai highlight remove event - props.superToolbar.emit('ai-highlight-remove'); + props.editor.emit('ai-highlight-remove'); // Remove all event listeners document.removeEventListener('mousedown', handleClickOutside); @@ -89,7 +92,7 @@ const promptText = ref(''); // Computed property to check if editor is in suggesting mode const isInSuggestingMode = computed(() => { - return props.superToolbar.activeEditor.isInSuggestingMode?.() || false; + return props.editor.isInSuggestingMode?.() || false; }); // Helper to get document XML from the editor if needed @@ -97,7 +100,7 @@ const getDocumentXml = () => { try { // Get document content as XML if available // This is a placeholder, implement according to your editor's capability - return props.superToolbar.activeEditor.state.doc.textContent || ''; + return props.editor.state.doc.textContent || ''; } catch (error) { console.error('Error getting document XML:', error); return ''; @@ -109,9 +112,9 @@ const handleTextChunk = (text) => { try { // If this is the first chunk and we're rewriting, remove the selected text if (props.selectedText && !textProcessingStarted.value) { - props.superToolbar.activeEditor.commands.deleteSelection(); + props.editor.commands.deleteSelection(); // Remove the ai highlight - props.superToolbar.emit('ai-highlight-remove'); + props.editor.emit('ai-highlight-remove'); textProcessingStarted.value = true; } @@ -143,7 +146,7 @@ const handleTextChunk = (text) => { // Update the document with only the new content if (newContent) { - props.superToolbar.activeEditor.commands.insertContent(newContent); + props.editor.commands.insertContent(newContent); previousText.value = textStr; } } catch (error) { @@ -166,7 +169,7 @@ const handleSubmit = async () => { try { // Enable track changes if in suggesting mode if (isInSuggestingMode.value) { - props.superToolbar.activeEditor.commands.enableTrackChanges(); + props.editor.commands.enableTrackChanges(); } // Get document content for context @@ -178,8 +181,8 @@ const handleSubmit = async () => { docText: '', documentXml: documentXml, config: { - // Pass the aiApiKey from superToolbar to the AI helper functions - apiKey: props.superToolbar.aiApiKey, + // Pass the aiApiKey to the AI helper functions + apiKey: props.key, }, }; @@ -205,9 +208,9 @@ const handleSubmit = async () => { generatedText = await rewrite(props.selectedText, promptText.value, options); // Remove the selected text - props.superToolbar.activeEditor.commands.deleteSelection(); + props.editor.commands.deleteSelection(); // Remove the ai highlight - props.superToolbar.emit('ai-highlight-remove'); + props.editor.emit('ai-highlight-remove'); } else { // Get generated text generatedText = await write(promptText.value, options); @@ -215,7 +218,7 @@ const handleSubmit = async () => { // Insert the generated text if (generatedText) { - props.superToolbar.activeEditor.commands.insertContent(generatedText); + props.editor.commands.insertContent(generatedText); } } @@ -228,7 +231,7 @@ const handleSubmit = async () => { promptText.value = ''; // Clear the input after submission // Only disable track changes if we enabled it (in suggesting mode) if (isInSuggestingMode.value) { - props.superToolbar.activeEditor.commands.disableTrackChanges(); + props.editor.commands.disableTrackChanges(); } isLoading.value = false; } diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 48c0bc4635..c90ebc2551 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -121,7 +121,8 @@ export const makeDefaultItems = (superToolbar, isDev = false, windowWidth, role, h(AIWriter, { handleClose, selectedText, - superToolbar, + editor: superToolbar.activeEditor, + key: superToolbar.config.aiApiKey, }), ], ); From e466138f316c913b644203402524cb0940cedb9b Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 26 Mar 2025 23:37:43 -0700 Subject: [PATCH 05/30] feat: add magic wand icon to superdoc icons --- packages/superdoc/src/icons.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/superdoc/src/icons.js b/packages/superdoc/src/icons.js index 97233bdac9..1a21828e6c 100644 --- a/packages/superdoc/src/icons.js +++ b/packages/superdoc/src/icons.js @@ -5,6 +5,7 @@ import usersIconSvg from '@harbour-enterprises/common/icons/users-solid.svg?raw' import checkIconSvg from '@harbour-enterprises/common/icons/check-solid.svg?raw'; import xmarkIconSvg from '@harbour-enterprises/common/icons/xmark-solid.svg?raw'; import ellipsisVerticalSvg from '@harbour-enterprises/common/icons/ellipsis-vertical-solid.svg?raw'; +import magicWandIconSvg from '@harbour-enterprises/common/icons/magic-wand-solid.svg?raw'; export const superdocIcons = { comment: commentIconSvg, @@ -15,4 +16,5 @@ export const superdocIcons = { acceptChange: checkIconSvg, rejectChange: xmarkIconSvg, overflow: ellipsisVerticalSvg, + ai: magicWandIconSvg, }; From dd0c357da6959b48f500712522a35c645e4dfb63 Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 26 Mar 2025 23:47:55 -0700 Subject: [PATCH 06/30] feat: integrate AI Writer functionality with cursor positioning and UI controls --- packages/superdoc/src/SuperDoc.vue | 165 ++++++++++++++++++++++++++--- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index ad79a670d8..b590d351a3 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -25,7 +25,7 @@ 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'; @@ -78,6 +78,10 @@ const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 }); // Hrbr Fields const hrbrFieldsLayer = ref(null); +// Add new state variables +const showAiWriter = ref(false); +const aiWriterPosition = reactive({ top: 0, left: 0 }); + const handleDocumentReady = (documentId, container) => { const doc = getDocument(documentId); doc.isReady = true; @@ -92,6 +96,7 @@ const handleDocumentReady = (documentId, container) => { const handleToolClick = (tool) => { const toolOptions = { comments: () => showAddComment(proxy.$superdoc), + ai: () => showAiWriterAtCursor(), }; if (tool in toolOptions) { @@ -498,6 +503,14 @@ const handlePdfClick = (e) => { handleSelectionStart(e); }; +watch(getFloatingComments, () => { + hasInitializedLocations.value = false; + nextTick(() => { + hasInitializedLocations.value = true; + }); +}); + +// AI Layer and Writer controls const aiLayer = ref(null); const handleAiHighlightAdd = () => { @@ -516,12 +529,59 @@ const handleAiHighlightRemove = () => { aiLayer.value.removeAiHighlight(); }; -watch(getFloatingComments, () => { - hasInitializedLocations.value = false; - nextTick(() => { - hasInitializedLocations.value = true; - }); -}); + +// Add a new function to show the AIWriter at cursor position +const showAiWriterAtCursor = () => { + if (!proxy.$superdoc.activeEditor || proxy.$superdoc.activeEditor.isDestroyed) { + console.error('[SuperDoc] Editor not available'); + return; + } + + try { + // Get the current cursor position + const editor = proxy.$superdoc.activeEditor; + const { view } = editor; + const { selection } = view.state; + + 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('[SuperDoc] Error displaying AIWriter:', error); + // Fallback position in center of editor + const editorDom = proxy.$superdoc.activeEditor.view.dom; + const rect = editorDom.getBoundingClientRect(); + aiWriterPosition.top = rect.top + 100 + 'px'; + aiWriterPosition.left = rect.left + 100 + 'px'; + showAiWriter.value = true; + } +}; + +const handleAiWriterClose = () => { + showAiWriter.value = false; +}; @@ -692,6 +773,7 @@ watch(getFloatingComments, () => { position: absolute; z-index: 3; display: flex; + flex-direction: column; gap: 6px; } @@ -749,4 +831,61 @@ 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; + 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; +} +/* Tools styles - end */ From ad582e6c8f6707cdc59d33833810ea5d068a8858 Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Thu, 27 Mar 2025 08:43:00 -0700 Subject: [PATCH 07/30] feat: enhance AI Writer with text selection highlighting and improved UI styles --- packages/superdoc/src/SuperDoc.vue | 41 ++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index b590d351a3..f3d8b1c2b3 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -543,6 +543,12 @@ const showAiWriterAtCursor = () => { 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 + proxy.$superdoc.emit('ai-highlight-add'); + } + let coords; try { // Try to get coordinates from the selection head @@ -581,6 +587,8 @@ const showAiWriterAtCursor = () => { const handleAiWriterClose = () => { showAiWriter.value = false; + // Remove the AI highlight when AIWriter is closed + proxy.$superdoc.emit('ai-highlight-remove'); }; @@ -594,8 +602,8 @@ const handleAiWriterClose = () => {
-
-
+
+
@@ -871,6 +879,7 @@ const handleAiWriterClose = () => { display: flex; align-items: center; justify-content: center; + position: relative; width: 50px; height: 50px; background-color: rgba(219, 219, 219, 0.6); @@ -887,5 +896,33 @@ const handleAiWriterClose = () => { 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 */ From bea95b49f052dcb539669e2434eda89d53d351ec Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Sun, 30 Mar 2025 21:03:03 -0700 Subject: [PATCH 08/30] feat: streamline AI Writer to always use streaming approach and close after first text chunk --- .../src/components/toolbar/AIWriter.vue | 41 ++++--------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index f6ceca4851..88377a28d2 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -148,6 +148,8 @@ const handleTextChunk = (text) => { if (newContent) { props.editor.commands.insertContent(newContent); previousText.value = textStr; + // Hide the AI Writer after the first chunk is received + props.handleClose(); } } catch (error) { console.error('Error handling text chunk:', error); @@ -186,40 +188,13 @@ const handleSubmit = async () => { }, }; - // @DEBUG - Use non-streaming for now - // Determine if we should use streaming or non-streaming - const useStreaming = true; // Set to true to use streaming - - if (useStreaming) { - // STREAMING APPROACH - if (props.selectedText) { - // Use rewriteStreaming for selected text - await rewriteStreaming(props.selectedText, promptText.value, options, handleTextChunk); - } else { - // Use writeStreaming for generating new text - await writeStreaming(promptText.value, options, handleTextChunk); - } + // Always use streaming approach + if (props.selectedText) { + // Use rewriteStreaming for selected text + await rewriteStreaming(props.selectedText, promptText.value, options, handleTextChunk); } else { - // NON-STREAMING APPROACH - let generatedText; - - if (props.selectedText) { - // Get rewritten text - generatedText = await rewrite(props.selectedText, promptText.value, options); - - // Remove the selected text - props.editor.commands.deleteSelection(); - // Remove the ai highlight - props.editor.emit('ai-highlight-remove'); - } else { - // Get generated text - generatedText = await write(promptText.value, options); - } - - // Insert the generated text - if (generatedText) { - props.editor.commands.insertContent(generatedText); - } + // Use writeStreaming for generating new text + await writeStreaming(promptText.value, options, handleTextChunk); } // If all is good, close the AI Writer From cef1d2239cc08daebe776e03a7470ebbb3c1ccff Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Mon, 31 Mar 2025 00:34:15 -0700 Subject: [PATCH 09/30] feat: enhance AIWriter component to support event emission through superToolbar --- .../src/components/toolbar/AIWriter.vue | 27 +++++++++++++------ .../src/components/toolbar/defaultItems.js | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index 88377a28d2..e4163b0aac 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -1,6 +1,6 @@ diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 5e6dbf546a..766c811ea5 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -378,8 +378,9 @@ export class SuperDoc extends EventEmitter { 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-add', (data) => this.emit('ai-highlight-add', data)); - this.toolbar.on('ai-highlight-remove', () => this.emit('ai-highlight-remove')); + this.toolbar.on('ai-highlight', ({ type, data }) => { + this.emit('ai-highlight', { type, data }); + }); this.once('editorCreate', () => this.toolbar.updateToolbarState()); } From 112c087a1587df7e7ff9517556524adc07a842e9 Mon Sep 17 00:00:00 2001 From: clarencepalmer Date: Wed, 23 Apr 2025 09:11:09 -0700 Subject: [PATCH 18/30] fix: prefix or remove global styles --- .../src/assets/styles/elements/toolbar-custom.css | 2 +- .../super-editor/src/components/toolbar/AIWriter.vue | 2 +- packages/super-editor/src/extensions/ai/ai-plugin.js | 3 +-- packages/superdoc/src/components/AiLayer/AiLayer.vue | 9 --------- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/super-editor/src/assets/styles/elements/toolbar-custom.css b/packages/super-editor/src/assets/styles/elements/toolbar-custom.css index f983bccaaa..16dc7d5472 100644 --- a/packages/super-editor/src/assets/styles/elements/toolbar-custom.css +++ b/packages/super-editor/src/assets/styles/elements/toolbar-custom.css @@ -48,7 +48,7 @@ } } -.ai-text-appear { +.sd-ai-text-appear { display: inline; opacity: 0; animation: aiTextAppear .7s ease-out forwards; diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index 5bba64b09f..1a015c7b4e 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -143,7 +143,7 @@ const handleTextChunk = (text) => { marks: [{ type: 'aiAnimationMark', attrs: { - class: 'ai-text-appear', + class: 'sd-ai-text-appear', 'data-mark-id': `ai-animation-${Date.now()}` } }], diff --git a/packages/super-editor/src/extensions/ai/ai-plugin.js b/packages/super-editor/src/extensions/ai/ai-plugin.js index 677fe855f0..1f97cce43a 100644 --- a/packages/super-editor/src/extensions/ai/ai-plugin.js +++ b/packages/super-editor/src/extensions/ai/ai-plugin.js @@ -115,8 +115,7 @@ const processAiHighlights = (editor, doc, highlightColor) => { pos, pos + node.nodeSize, { - style: `background-color: ${highlightColor}33;`, // 33 is 20% opacity in hex - class: 'ai-highlight', + style: `background-color: ${highlightColor}33; border-radius: 4px; transition: background-color 250ms ease;`, // 33 is 20% opacity in hex } ); decorations.push(deco); diff --git a/packages/superdoc/src/components/AiLayer/AiLayer.vue b/packages/superdoc/src/components/AiLayer/AiLayer.vue index 42f066cd0e..3db99e0d4b 100644 --- a/packages/superdoc/src/components/AiLayer/AiLayer.vue +++ b/packages/superdoc/src/components/AiLayer/AiLayer.vue @@ -67,15 +67,6 @@ defineExpose({ -