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`
{
- const item = e.detail.item;
- if (item && item.command) {
- props.command(item);
- }
- }}"
+ const item = e.detail.item;
+ if (item && item.command) {
+ props.command(item);
+ }
+ }}"
>
`;
- 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`