diff --git a/blog/config/application.properties b/blog/config/application.properties index 6015ca4a9..19dc4ad3b 100644 --- a/blog/config/application.properties +++ b/blog/config/application.properties @@ -6,4 +6,7 @@ quarkus.asciidoc.attributes.icons=font quarkus.asciidoc.attributes.source-highlighter=highlight.js site.slugify-files=false %dev.site.draft=true -%dev.site.future=true \ No newline at end of file +%dev.site.future=true + +# Use Podman instead of Docker for dev services +quarkus.devservices.container-runtime=podman diff --git a/blog/pom.xml b/blog/pom.xml index 014d76c28..bcb2a0708 100644 --- a/blog/pom.xml +++ b/blog/pom.xml @@ -12,7 +12,7 @@ UTF-8 quarkus-bom io.quarkus - 3.34.2 + 3.31.1 999-SNAPSHOT true 3.5.5 @@ -78,6 +78,11 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + io.quarkiverse.chappie + quarkus-chappie + 1.6.0 + org.mvnpm highlight.js diff --git a/roq-editor/deployment/src/main/java/io/quarkiverse/roq/editor/deployment/RoqEditorProcessor.java b/roq-editor/deployment/src/main/java/io/quarkiverse/roq/editor/deployment/RoqEditorProcessor.java index abbb0e71d..778140948 100644 --- a/roq-editor/deployment/src/main/java/io/quarkiverse/roq/editor/deployment/RoqEditorProcessor.java +++ b/roq-editor/deployment/src/main/java/io/quarkiverse/roq/editor/deployment/RoqEditorProcessor.java @@ -9,6 +9,8 @@ import io.quarkiverse.roq.editor.runtime.devui.RoqEditorConfig; import io.quarkiverse.roq.editor.runtime.devui.RoqEditorJsonRPCService; import io.quarkiverse.roq.frontmatter.deployment.items.scan.RoqFrontMatterQuteMarkupBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Produce; @@ -56,7 +58,7 @@ void setupConsole(HttpRootPathBuildItem rp, NonApplicationRootPathBuildItem np, : c.getOptionalValue("quarkus.http.port", String.class).orElse("8080"); String protocol = isInsecureDisabled ? "https" : "http"; - context.reset(new ConsoleCommand('c', "Open the Roq Editor in a browser", null, + context.reset(new ConsoleCommand('m', "Open the Roq Editor in a browser", null, () -> IdeHelper.openBrowser(rp, np, protocol, "/q/dev-ui/quarkus-roq-editor/roq-editor", host, port))); } @@ -64,16 +66,19 @@ void setupConsole(HttpRootPathBuildItem rp, NonApplicationRootPathBuildItem np, CardPageBuildItem create( RoqEditorConfig config, CurateOutcomeBuildItem bi, + Capabilities capabilities, List markupList) { CardPageBuildItem pageBuildItem = new CardPageBuildItem(); pageBuildItem.addPage(Page.webComponentPageBuilder() .title("Roq Editor") .componentLink("qwc-roq-editor.js") .icon("font-awesome-solid:pencil")); + final boolean assistantIsAvailable = capabilities.isPresent(Capability.ASSISTANT); List markups = new ArrayList<>(markupList.stream().map(RoqFrontMatterQuteMarkupBuildItem::name).toList()); markups.add("html"); pageBuildItem.addBuildTimeData("markups", markups); pageBuildItem.addBuildTimeData("config", config); + pageBuildItem.addBuildTimeData("assistantIsAvailable", assistantIsAvailable); return pageBuildItem; } diff --git a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-command.js b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-command.js index e3e1fb467..3dafdaeaa 100644 --- a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-command.js +++ b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-command.js @@ -9,252 +9,311 @@ import './slash-menu.js'; import {showPrompt} from "../../prompt-dialog.js"; import {renderImageForm} from "./image.js"; +// AI command prefix +const AI_PREFIX = 'ai '; + // Define all available block types with their commands const BLOCK_TYPES = [ - { - label: 'Text', - icon: 'T', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setParagraph().run(); - } - }, - { - label: 'Heading 1', - icon: 'H₁', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(); - } - }, - { - label: 'Heading 2', - icon: 'H₂', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(); - } - }, - { - label: 'Heading 3', - icon: 'H₃', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(); - } - }, - { - label: 'Heading 4', - icon: 'H₄', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 4 }).run(); - } - }, - { - label: 'Heading 5', - icon: 'H₅', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 5 }).run(); - } - }, - { - label: 'Heading 6', - icon: 'H₆', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 6 }).run(); - } - }, - { - label: 'Bullet List', - icon: '≡', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleBulletList().run(); - } - }, - { - label: 'Numbered List', - icon: '1≡', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + { + label: 'Text', + icon: 'T', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setParagraph().run(); + } + }, + { + label: 'Heading 1', + icon: 'H₁', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(); + } + }, + { + label: 'Heading 2', + icon: 'H₂', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(); + } + }, + { + label: 'Heading 3', + icon: 'H₃', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(); + } + }, + { + label: 'Heading 4', + icon: 'H₄', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 4 }).run(); + } + }, + { + label: 'Heading 5', + icon: 'H₅', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 5 }).run(); + } + }, + { + label: 'Heading 6', + icon: 'H₆', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 6 }).run(); + } + }, + { + label: 'Bullet List', + icon: '≡', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + } + }, + { + label: 'Numbered List', + icon: '1≡', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); } }, { label: 'Image', - icon: '🏞️', + icon: '�️', command: ({ editor, range }) => { showPrompt('Add an image:', { title: '', src: '', alt: ''}, renderImageForm).then(({ src, title, alt}) => { if (src) { editor.chain().focus().deleteRange(range).setParagraph().setImage({ src, title, alt }).run(); } }); - } - }, - { - label: 'Code Block', - icon: '{}', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); - } - }, - { - label: 'Raw Block', - icon: '', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).insertContent({ type: 'rawBlock' }).run(); - } - }, - { - label: 'Table', - icon: '▦', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - } } + }, + { + label: 'Code Block', + icon: '{}', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + } + }, + { + label: 'Raw Block', + icon: '', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertContent({ type: 'rawBlock' }).run(); + } + }, + { + label: 'Table', + icon: '▦', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + } + } ]; +// AI command - dispatches event to be handled by visual-editor +const createAiCommand = () => ({ + label: 'AI', + icon: '✨', + description: 'Generate content with AI', + isAiCommand: true, + command: ({ editor, range, prompt }) => { + if (!prompt) { + editor.chain().focus().deleteRange(range).run(); + return; + } + + // Delete the slash command text + editor.chain().focus().deleteRange(range).run(); + + // Dispatch event to be handled by parent component + const event = new CustomEvent('generate-ai-content', { + bubbles: true, + composed: true, + detail: { prompt, editor } + }); + editor.view.dom.dispatchEvent(event); + } +}); + /** * Create the SlashCommand extension */ export const SlashCommand = Extension.create({ - name: 'slashCommand', + name: 'slashCommand', - addOptions() { - return { - suggestion: { - char: '/', - startOfLine: true, - command: ({ editor, range, props }) => { - props.command({ editor, range }); - }, - decorationContent: "Filter blocks..." - }, - }; - }, + addOptions() { + return { + assistantIsAvailable: false, + suggestion: { + char: '/', + startOfLine: true, + allowSpaces: true, + command: ({ editor, range, props }) => { + if (props.isAiCommand && props.aiPrompt) { + props.command({ editor, range, prompt: props.aiPrompt }); + } else { + props.command({ editor, range }); + } + }, + decorationContent: "Filter blocks..." + }, + }; + }, - addCommands() { - return { - openSlashMenu: - (pos) => - ({editor, chain}) => { - if (pos == null) { - return false; - } - const $root = editor.$pos(pos); + addCommands() { + return { + openSlashMenu: + (pos) => + ({ editor, chain }) => { + if (pos == null) { + return false; + } + const $root = editor.$pos(pos); - if ($root.node.type.name !== 'doc') return false + if ($root.node.type.name !== 'doc') return false - const $from = $root.firstChild; - if(!$from.node.isTextblock) return false; - console.log($from.textContent); - const hasContent = $from.textContent?.length > 0; - if(hasContent){ - return chain() - .focus() - .insertContentAt($from.to, [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '/', - }, - ], - }], { updateSelection:true }) - .run(); - } + const $from = $root.firstChild; + if (!$from.node.isTextblock) return false; + console.log($from.textContent); + const hasContent = $from.textContent?.length > 0; + if (hasContent) { + return chain() + .focus() + .insertContentAt($from.to, [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '/', + }, + ], + }], { updateSelection: true }) + .run(); + } - return chain() - .focus() - .insertContentAt($from.pos, '/', { updateSelection:true }) - .run() - }, + return chain() + .focus() + .insertContentAt($from.pos, '/', { updateSelection: true }) + .run() + }, - } - }, + } + }, + + + addProseMirrorPlugins() { + const { assistantIsAvailable } = this.options; + + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + items: ({ query }) => { + const lowerQuery = query.toLowerCase(); + if (assistantIsAvailable && lowerQuery.startsWith(AI_PREFIX)) { + const aiPrompt = query.substring(AI_PREFIX.length).trim(); + const aiCommand = createAiCommand(); + return [{ + ...aiCommand, + aiPrompt, + label: aiPrompt ? `AI: "${aiPrompt}"` : 'AI: Type your prompt...', + description: aiPrompt ? 'Press Enter to generate' : 'Type what you want to generate' + }]; + } - addProseMirrorPlugins() { - return [ - Suggestion({ - editor: this.editor, - ...this.options.suggestion, - items: ({query}) => { - return BLOCK_TYPES.filter(item => - item.label.toLowerCase().includes(query.toLowerCase()) - ); - }, - render: () => { - let component; - let popup; + const items = BLOCK_TYPES.filter(item => + item.label.toLowerCase().includes(lowerQuery) + ); - const updatePosition = (clientRect) => { - if (popup && clientRect) { - const rect = clientRect(); - if (rect) { - popup.style.left = `${rect.left}px`; - popup.style.top = `${rect.bottom + 4}px`; - } - } - }; + if (assistantIsAvailable && 'ai'.includes(lowerQuery)) { + const aiCommand = createAiCommand(); + items.push({ + ...aiCommand, + description: 'Type /ai followed by your prompt' + }); + } + + return items; + }, + render: () => { + let component; + let popup; + + const updatePosition = (clientRect) => { + if (popup && clientRect) { + const rect = clientRect(); + if (rect) { + popup.style.left = `${rect.left}px`; + popup.style.top = `${rect.bottom + 4}px`; + } + } + }; - const renderPopup = (props) => { - const template = html` + const renderPopup = (props) => { + const template = html` `; - render(template, popup); - component = popup.querySelector('qwc-slash-menu'); - }; + render(template, popup); + component = popup.querySelector('qwc-slash-menu'); + }; - return { - onStart: (props) => { - // Create popup container - popup = document.createElement('div'); - popup.style.position = 'absolute'; - popup.style.zIndex = '1000'; - document.body.appendChild(popup); + return { + onStart: (props) => { + // Create popup container + popup = document.createElement('div'); + popup.style.position = 'absolute'; + popup.style.zIndex = '1000'; + document.body.appendChild(popup); - // Render the slash menu using Lit template - renderPopup(props); - updatePosition(props.clientRect); - }, + // Render the slash menu using Lit template + renderPopup(props); + updatePosition(props.clientRect); + }, - onUpdate: (props) => { - renderPopup(props); - updatePosition(props.clientRect); - }, + onUpdate: (props) => { + renderPopup(props); + updatePosition(props.clientRect); + }, - onKeyDown: (props) => { - if (props.event.key === 'Escape') { - if (popup) { - popup.remove(); - popup = null; - } - return true; - } + onKeyDown: (props) => { + if (props.event.key === 'Escape') { + if (popup) { + popup.remove(); + popup = null; + } + return true; + } - if (component) { - return component.onKeyDown(props.event); - } + if (component) { + return component.onKeyDown(props.event); + } - return false; - }, + return false; + }, - onExit: () => { - if (popup) { - popup.remove(); - popup = null; - } - component = null; - }, - }; - }, - }), - ]; - }, + onExit: () => { + if (popup) { + popup.remove(); + popup = null; + } + component = null; + }, + }; + }, + }), + ]; + }, }); diff --git a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-menu.js b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-menu.js index a390eac7a..f41166707 100644 --- a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-menu.js +++ b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/extensions/slash-menu.js @@ -63,6 +63,15 @@ export class SlashMenu extends LitElement { font-weight: 600; color: var(--lumo-secondary-text-color); } + .item-content { + display: flex; + flex-direction: column; + gap: 2px; + } + .item-description { + font-size: var(--lumo-font-size-xs); + color: var(--lumo-secondary-text-color); + } .slash-menu-empty { padding: var(--lumo-space-m); text-align: center; @@ -86,9 +95,12 @@ export class SlashMenu extends LitElement { `; } + const hasAiCommand = this.items.some(item => item.isAiCommand); + const menuLabel = hasAiCommand && this.items.length === 1 ? 'AI Assistant' : 'Style'; + return html`
-
Style
+
${menuLabel}
${item.icon} - ${item.label} +
+ ${item.label} + ${item.description ? html`${item.description}` : ''} +
`)}
diff --git a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/visual-editor.js b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/visual-editor.js index 8ab7c0ee0..b65f1c076 100644 --- a/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/visual-editor.js +++ b/roq-editor/deployment/src/main/resources/dev-ui/components/visual-editor/visual-editor.js @@ -2,6 +2,7 @@ import '@qomponent/qui-code-block'; import '@vaadin/button'; import '@vaadin/icon'; import {css, html} from 'lit'; +import {assistantIsAvailable} from 'build-time-data'; import { BubbleMenu, Placeholder, @@ -401,7 +402,9 @@ export class RoqVisualEditor extends BaseEditor { render: () => this.shadowRoot.getElementById('gutter-menu'), dragHandleWidth: 24, }), - SlashCommand, + SlashCommand.configure({ + assistantIsAvailable: assistantIsAvailable, + }), Placeholder.configure({ placeholder: "Write some text, or type '/' for blocks & commands", }), diff --git a/roq-editor/deployment/src/main/resources/dev-ui/qwc-roq-editor.js b/roq-editor/deployment/src/main/resources/dev-ui/qwc-roq-editor.js index e9f04db01..8eaaa3935 100644 --- a/roq-editor/deployment/src/main/resources/dev-ui/qwc-roq-editor.js +++ b/roq-editor/deployment/src/main/resources/dev-ui/qwc-roq-editor.js @@ -174,6 +174,7 @@ export class QwcRoqEditor extends LitElement { .content="${this._fileContent}" @save-content="${this._onSaveContent}" @page-sync-path="${this._onPageSyncPath}" + @generate-ai-content="${this._onGenerateAiContent}" > `; @@ -402,6 +403,46 @@ export class QwcRoqEditor extends LitElement { }); } + async _onGenerateAiContent(e) { + const { prompt, editor } = e.detail; + if (!editor || editor.isDestroyed || !prompt) { + return; + } + + // Insert a placeholder while generating + const placeholderText = 'Generating content...'; + editor.chain().focus().insertContent(placeholderText).run(); + + try { + const response = await this.jsonRpc.generateContent({ message: prompt }); + const result = response.result; + + // Get current selection position + const { from } = editor.state.selection; + + // Calculate the range of the placeholder text + const placeholderFrom = from - placeholderText.length; + const placeholderTo = from; + + // Replace placeholder with generated content + editor.chain() + .focus() + .deleteRange({ from: placeholderFrom, to: placeholderTo }) + .insertContent(result.content || result) + .run(); + } catch (error) { + console.error('Error generating content:', error); + // Remove placeholder on error + const { from } = editor.state.selection; + const placeholderFrom = from - placeholderText.length; + editor.chain() + .focus() + .deleteRange({ from: placeholderFrom, to: from }) + .insertContent('Error generating content. Please try again.') + .run(); + } + } + _onSaveContent(e) { const {content, path, date, title} = e.detail; const detail = e.detail; diff --git a/roq-editor/runtime/pom.xml b/roq-editor/runtime/pom.xml index 8945f2c59..725d55030 100644 --- a/roq-editor/runtime/pom.xml +++ b/roq-editor/runtime/pom.xml @@ -37,6 +37,10 @@ io.vertx vertx-web-client + + io.quarkus + quarkus-assistant-dev + diff --git a/roq-editor/runtime/src/main/java/io/quarkiverse/roq/editor/runtime/devui/RoqEditorJsonRPCService.java b/roq-editor/runtime/src/main/java/io/quarkiverse/roq/editor/runtime/devui/RoqEditorJsonRPCService.java index 9514affe2..40347bfdd 100644 --- a/roq-editor/runtime/src/main/java/io/quarkiverse/roq/editor/runtime/devui/RoqEditorJsonRPCService.java +++ b/roq-editor/runtime/src/main/java/io/quarkiverse/roq/editor/runtime/devui/RoqEditorJsonRPCService.java @@ -11,6 +11,9 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -26,6 +29,7 @@ import io.quarkiverse.roq.frontmatter.runtime.model.Page; import io.quarkiverse.roq.frontmatter.runtime.model.Site; import io.quarkiverse.tools.stringpaths.StringPaths; +import io.quarkus.assistant.runtime.dev.Assistant; import io.smallrye.common.annotation.Blocking; @ApplicationScoped @@ -38,12 +42,19 @@ public class RoqEditorJsonRPCService { "markdown", "md", "html", "html"); + private static final String SYSTEM_MESSAGE = "You are a blog content writer. Generate blog post body content in Markdown based on the user's request. Do not include frontmatter or " + + + "metadata - only the content. Start directly with the content, no preamble."; + @Inject private Site site; @Inject private RoqSiteConfig config; + @Inject + private Optional assistant; + @Blocking public List getPosts() { return site.collections().get("posts").stream() @@ -388,4 +399,18 @@ public boolean isError() { } } + @Blocking + public CompletionStage> generateContent(String message) { + if (assistant.isPresent()) { + return assistant.get().assistBuilder() + .systemMessage(SYSTEM_MESSAGE) + .userMessage(message) + .responseType(ContentResponse.class) + .assist(); + } + return CompletableFuture.failedStage(new RuntimeException("Assistant is not available")); + } + + final record ContentResponse(String content) { + } }